@typeroll/mcp-server 0.26.2 → 0.28.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.
@@ -4,26 +4,26 @@
4
4
  // the hosted portal (no runtime disk read). See scripts/embed-content.mjs.
5
5
  /* eslint-disable */
6
6
  export const BUNDLED_SKILLS = {
7
- "tr-blog": "---\nname: tr-blog\ndescription: Use when the user wants to set up a blog, news section, podcast feed, or any time-ordered article-style content on a Typeroll site. Triggers on \"add a blog\", \"set up news\", \"article section\", \"create posts\", \"podcast\", \"inlägg\", \"nyheter\", \"avsnitt\", or any feed-of-dated-entries pattern.\n---\n\n# Set up a blog / news section\n\nA blog in Typeroll is a **collection with `item_template_html` + `route_template`**. Every published item materialises as its own static page at build time — there is **no need to call `create_page` per article**. The detail design lives once in `item_template_html`; the listing lives once in a page with a `<!-- typeroll:listing -->` marker that `regenerate_collection_listing` refreshes.\n\nIf you find yourself about to create 20 pages for 20 articles, stop — you're using the old pattern. The recipe below is the right one.\n\n## Preconditions\n\n- Site exists with working header/footer.\n- Collection name picked (`blog`, `news`, `artiklar`, `podcast`, `avsnitt`).\n- URL structure picked: `/blog/{slug}`, `/news/{slug}`, `/podd/{slug}`. Changing later renames every URL.\n\n## Recipe\n\n### 1. Create the collection with detail template baked in\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Artikel\",\n \"label_plural\": \"Artiklar\",\n \"icon\": \"📝\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"item_template_html\": \"<article class=\\\"post\\\">\\n <header class=\\\"post__header\\\">\\n <time>{{date}}</time>\\n <h1>{{title}}</h1>\\n {{#author}}<p class=\\\"byline\\\">av {{author}}</p>{{/author}}\\n </header>\\n {{#image}}<img class=\\\"post__hero\\\" src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\" />{{/image}}\\n <div class=\\\"post__body\\\">{{{body}}}</div>\\n</article>\\n<style>\\n.post{max-width:42rem;margin:3rem auto;padding:0 1rem}\\n.post__header time{color:var(--color-text-light);font-size:0.85rem}\\n.post__header h1{font-family:var(--font-heading);font-size:2.25rem;margin:0.25rem 0}\\n.byline{color:var(--color-text-light);font-size:0.9rem}\\n.post__hero{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:0.5rem;margin:2rem 0}\\n.post__body{font-size:1.05rem;line-height:1.7}\\n.post__body h2{font-family:var(--font-heading);margin-top:2rem}\\n.post__body p{margin-bottom:1.25rem}\\n</style>\",\n \"fields\": [\n {\"name\": \"title\", \"type\": \"text\", \"label\": \"Rubrik\", \"required\": true},\n {\"name\": \"slug\", \"type\": \"text\", \"label\": \"URL-slug\", \"required\": true},\n {\"name\": \"date\", \"type\": \"date\", \"label\": \"Datum\", \"required\": true},\n {\"name\": \"author\", \"type\": \"text\", \"label\": \"Författare\"},\n {\"name\": \"excerpt\", \"type\": \"textarea\", \"label\": \"Ingress\"},\n {\"name\": \"body\", \"type\": \"richtext\", \"label\": \"Brödtext\"},\n {\"name\": \"image\", \"type\": \"image\", \"label\": \"Omslagsbild\"}\n ]\n}\n```\n\n**About `item_template_html`:**\n- `{{field}}` HTML-escapes the value (use for plain text).\n- `{{{field}}}` leaves it raw (use for `body` and any richtext).\n- `{{#field}}...{{/field}}` is a conditional — render the block only if the field is truthy. Useful for optional images, authors, etc.\n- **No loops, no nested conditionals.** If you need either, pre-render the HTML in a field on the item itself (see tr-collection-template for patterns).\n\n**Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.\n\n### 2. Seed with real content\n\n```\ncreate_collection_item collection=\"blog\" status=\"published\" fields={\n \"title\": \"Vår designfilosofi\",\n \"slug\": \"var-designfilosofi\",\n \"date\": \"2025-05-15\",\n \"author\": \"Anna Lindström\",\n \"excerpt\": \"Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.\",\n \"body\": \"<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>\",\n \"image\": \"https://cdn.typeroll.com/...\"\n}\n```\n\nIf `image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.\n\nEach published item with this collection's `route_template` automatically becomes `/blog/{slug}` at deploy time — you do **not** need to call `create_page`.\n\n### 3. Build the listing page (once)\n\nCreate a single page that hosts the listing. The HTML between the `typeroll:listing` markers gets regenerated whenever the collection changes:\n\n```\ncreate_page title=\"Artiklar\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section class=\\\"blog-listing\\\">\n <div class=\\\"container\\\">\n <h1 class=\\\"section-title\\\">Artiklar</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n </div>\n</section>\n<style>\n.blog-listing{padding:4rem 0}\n.blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem;margin-top:2rem}\n.blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}\n.blog-card a{text-decoration:none;display:block;color:var(--color-text)}\n.blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}\n.blog-card__body{padding:1.5rem}\n.blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}\n.blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}\n.blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}\n.blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}\n</style>\"\n```\n\n### 4. Populate the listing (and re-run after every change)\n\n```\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\">\n <a href=\\\"{{url}}\\\">\n {{#image}}<img src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\">{{/image}}\n <div class=\\\"blog-card__body\\\">\n <time class=\\\"blog-card__date\\\">{{date}}</time>\n <h2 class=\\\"blog-card__title\\\">{{title}}</h2>\n <p class=\\\"blog-card__excerpt\\\">{{excerpt}}</p>\n <span class=\\\"blog-card__cta\\\">Läs mer →</span>\n </div>\n </a>\n </article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\n`{{url}}` resolves through the collection's `route_template`. Only the content between the markers is replaced; everything else on the page stays put. Re-run this whenever items are added, edited, or unpublished.\n\n### 5. Update the header partial to link to the listing\n\n```\nread_partial partial_id=\"header\"\nreplace_partial partial_id=\"header\" html_content=\"<updated with /blog link>\"\n```\n\n### 6. Preview a single article\n\n```\nget_preview_link collection_name=\"blog\" item_id=\"<id>\"\n```\n\nThe returned URL renders the item through `item_template_html` exactly as it'll appear in production.\n\n### 7. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nThe build produces one HTML file per published article at `/blog/<slug>` plus the listing at `/blog`, and includes them all in `sitemap.xml`.\n\n## Adding a new article later\n\n```\ncreate_collection_item collection=\"blog\" status=\"published\" fields={ ... }\nregenerate_collection_listing collection=\"blog\" page_id=\"blog\" item_template=\"...\" wrap_open=\"...\" wrap_close=\"...\"\ntrigger_deploy\n```\n\nThree calls. No per-article `create_page`. No HTML diffing by hand.\n\n## Pitfalls\n\n- **Don't fall back to \"one page per article\".** That was the pre-`item_template_html` pattern. It's strictly worse now: design changes mean editing N pages, you lose `{{url}}` resolution in listings, sitemap doesn't include items, previews can't surface a per-item URL — and the API will reject your attempt anyway. `create_page` rejects slugs containing slashes (\"Invalid slug … slugs must not contain slashes\"), so `slug: \"blog/foo\"` doesn't even get through. The collection's `route_template` is the only path to nested URLs.\n- **Slugs must be unique within the collection.** `regenerate_collection_listing` will silently drop items where `slug` is missing; the listing count will be lower than the item count.\n- **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.\n- **Listing goes stale if you forget step 4.** Every item change needs `regenerate_collection_listing`. Add it to your mental checklist after every `create/update_collection_item`.\n- **`{{#field}}...{{/field}}` only checks truthiness.** Empty string and the field being absent both count as falsy. If you need \"render this block when `published_at` is later than today\", do it in the data step — set a flag field.\n- **Template too clever.** Mustache substitution has no loops or arithmetic. For an article with chapter timestamps, multiple authors, a guest with nested links — pre-render the HTML into a single field at `create_collection_item` time. See `tr-collection-template` for concrete patterns.\n\n## When you want a page that ISN'T a collection item\n\nA normal `create_page` is still right for:\n- The blog's about/contact pages.\n- Editorial standalone features.\n- Anything that doesn't fit the \"list of dated entries\" mould.\n\nJust don't use `create_page` *for the entries themselves*.\n",
8
- "tr-brand": "---\nname: tr-brand\ndescription: Use when the user asks to create a brand identity, design system, or visual style for a site. Triggers on \"create a brand\", \"design the look\", \"choose colors\", \"pick fonts\", \"make it look like [reference]\", or \"rebrand the site\". Produces a cohesive palette, typography scale, and CSS custom properties applied to an existing site.\n---\n\n# Design a brand identity for a Typeroll site\n\nThis skill turns a brief (or a reference URL/screenshot) into a complete\nvisual design system applied to the site's settings and partials.\n\n## Preconditions\n\n- Site exists and MCP is configured.\n- You have at least one of: industry, mood words, reference URL, existing\n logo colors, or competitor sites to contrast with.\n\n## Step 1 — Gather context\n\nAsk (or infer from the brief):\n\n1. **Industry + audience.** Law firm → formal, trust. Café → warm, approachable.\n Tech startup → clean, modern. Interior design → refined, editorial.\n2. **Mood words.** 3–5 adjectives the brand should feel: \"minimal, Nordic,\n calm\" or \"bold, energetic, playful\".\n3. **Reference.** A URL, a screenshot, or a competitor they like (and what\n they want to be different from it).\n4. **Must-keep.** Existing logo color? Legal industry color conventions?\n\nIf the user provided a URL, fetch it and note the dominant colors,\ntypeface categories, and layout density.\n\n## Step 2 — Build the palette\n\nA Typeroll site uses 7 color tokens:\n\n| Token | Role | Design rule |\n|---|---|---|\n| `primary` | Brand identity. CTA buttons, active nav, links. | High contrast on `background`. |\n| `secondary` | Header, footer, darker sections. | Darker or more neutral than primary. |\n| `accent` | Highlights, price tags, badges, hover states. | High-energy complement. |\n| `background` | Page background. | Near-white for light themes, near-black for dark. |\n| `surface` | Cards, input boxes, code blocks. | Slightly off from `background`. |\n| `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |\n| `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |\n\n**Palette recipes by mood:**\n\n*Nordic / minimal:*\n```\nprimary: #1f2a30 secondary: #142027 accent: #c9b89a\nbackground: #faf8f4 surface: #f2ede5 text: #1f2a30 text_light: #7a7265\n```\n\n*Warm / artisan:*\n```\nprimary: #3d2b1f secondary: #2a1d14 accent: #c8860a\nbackground: #fdf6ee surface: #f7ede0 text: #1a1008 text_light: #8a7060\n```\n\n*Modern / tech:*\n```\nprimary: #2563eb secondary: #1e293b accent: #f59e0b\nbackground: #ffffff surface: #f8fafc text: #0f172a text_light: #64748b\n```\n\n*Editorial / dark:*\n```\nprimary: #e2c08d secondary: #0f0f0f accent: #e2c08d\nbackground: #0f0f0f surface: #1a1a1a text: #f5f5f0 text_light: #a0a090\n```\n\nCheck WCAG contrast ratios mentally: text on background must be ≥4.5:1.\nThe online tool `https://webaim.org/resources/contrastchecker/` is useful\nbut not accessible during a tool call — reason about perceived contrast\ninstead (light grey on white = bad; dark grey on white = fine).\n\n## Step 3 — Choose typefaces\n\nPick from high-quality Google Fonts pairings:\n\n| Heading | Body | Mood |\n|---|---|---|\n| Cormorant Garamond | Raleway | Luxury, editorial |\n| Playfair Display | Source Sans 3 | Classic, readable |\n| DM Serif Display | DM Sans | Contemporary, clean |\n| Fraunces | Mulish | Artisan, craft |\n| Syne | Inter | Bold, modern |\n| Plus Jakarta Sans | Plus Jakarta Sans | Clean, versatile |\n| Libre Baskerville | Libre Franklin | Traditional, trustworthy |\n\nSame font for heading and body is fine if it has enough weight variation\n(Inter at 700 + 400 works well).\n\n`size_base` should be 16 for most sites; 17–18 for text-heavy editorial\nsites; 15 for dense dashboards.\n\n## Step 4 — Apply to the site\n\nOne call sets everything:\n\n```\nupdate_site_settings {\n \"colors\": { ...all 7 tokens },\n \"fonts\": { \"heading\": \"...\", \"body\": \"...\", \"size_base\": 16 },\n \"custom_css\": \"/* optional: utility classes or @keyframes */\"\n}\n```\n\nRead back to confirm: `read_site_settings`.\n\n**Inside a redesign branch, scope the brand to the branch** — pass\n`version=\"<branch>\"` on `update_site_settings` (and `read_site_settings`).\nColors / fonts / logo / custom_css then live on the branch (copy-on-write,\nchain-fallback to main for anything you don't override) and don't touch the\nlive site until you `merge_branch`. This is the correct way to rebrand on a\nbranch — don't hack the palette into a `:root{}` override in the header\npartial just to keep it off live; that's no longer necessary.\n\n### Site icons — always propose them, never leave them empty\n\nEvery site gets a favicon + apple touch icon as part of brand setup:\n\n1. **Brand assets exist** (favicon-*.png, app icon, symbol): upload the\n right sizes via `upload_media_inline` (favicon: 32–64px PNG or SVG;\n apple touch icon: 180×180 PNG) and set BOTH in one call:\n `update_site_settings { \"favicon\": \"<url>\", \"apple_touch_icon\": \"<url>\" }`.\n2. **No icon assets:** derive a proposal instead of skipping — crop the\n logo's symbol to a square and resize locally (`sips -z 180 180 in.png\n --out icon-180.png` on macOS, or ImageMagick), or generate a simple\n icon candidate with the imagegen lab (see `tr-imagegen`; respect the\n style profile, no text). Upload, set, and tell the user it's a\n proposal they can swap.\n\nA site shipping with the browser's default globe icon is a build gap —\ntreat icons like the logo: part of done.\n\n## Step 5 — Update partials to use the new palette\n\nPartials that hardcoded hex colors need updating. Fetch the header:\n\n```\nread_partial partial_id=\"header\"\n```\n\nIf it has hardcoded colors, replace them with CSS variable references\n(`var(--color-primary)`) and call `replace_partial`:\n\n```\nreplace_partial partial_id=\"header\" html_content=\"<updated HTML>\"\n```\n\nSame for footer.\n\n## Step 6 — Custom CSS for advanced tokens (optional)\n\nIf the brand needs things beyond the 7 base tokens — e.g. a gradient,\na special border radius, or a branded highlight color — add them via\n`custom_css`:\n\n```css\n:root {\n --brand-gradient: linear-gradient(135deg, var(--color-primary), var(--color-accent));\n --radius-brand: 2px; /* sharp corners for formal brands */\n --letter-spacing-display: -0.03em; /* tight tracking for display headings */\n}\n```\n\nThen reference `var(--brand-gradient)` etc. in page HTML and partials.\n\n## Step 4b — Section + layout design defaults\n\nThese are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-collection-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.\n\n### One signal per section boundary\n\nUse **either** a background-color shift **or** a horizontal divider line at a section transition — never both stacked. They serve the same purpose; stacking them looks busy.\n\n- Default: alternating `.section` / `.section.alt` with a bg shift is enough.\n- A standalone divider line (gradient/keyline) is reserved for the hero → body boundary, where the bg already shifts.\n\n### Sections are full-bleed; content is container-width\n\nThe section element ALWAYS spans the full viewport (its bg, border, decorative line). Content inside is constrained to a readable column.\n\n**Block-mode pages (the default):** this is native. Top-level\n`core/section` blocks are full-bleed out of the box — set `background`\non the section and it runs edge-to-edge, meeting the header with zero\ngap; the section's `width` field (narrow/normal/wide/full) constrains\nthe content column. **NEVER add 100vw negative-margin hacks on block\npages** — they double-bleed and break. Anchor ids / custom classes on\nsections are safe from template_capabilities_version ≥ 0.15.3 (older\nversions wrapped the section in a div and silently killed full-bleed —\nthere, put the anchor on a block inside the section).\n\n**HTML-mode pages (`html_content`) only:** the renderer wraps the body\nin `<main class=\"page-content\">` with `max-width: var(--container-medium)`\n— a section's bg-color rule alone gives a \"1080px-wide stripe in the\nmiddle of the page\", which is wrong. Every section that has a bg/border\nmust apply the negative-margin escape:\n\n```css\n.my-page .section {\n position: relative;\n margin-left: calc(50% - 50vw);\n margin-right: calc(50% - 50vw);\n width: 100vw;\n padding: 74px 0;\n}\n.my-page .section.alt { background: #fff }\n```\n\nThe first section (hero) also wants `margin-top: -32px` to cancel `.page-content`'s top padding. Do NOT wrap the page in `overflow-x: clip` — it cancels the bleed.\n\n### Cards sit directly on the section bg — never on a matching bg\n\nA card with a white bg inside a white-bg section creates a redundant \"white plate on white\" effect. Two enforcement rules:\n\n1. Cards on the default page bg (`var(--color-background)`) can use `background: #fff` + border. ✓\n2. Cards on `.section.alt` (which has `background: #fff`) must drop their own bg:\n - **Single-card section** (one card in a section): drop all chrome (bg, border, accent line). Just content; the section's bg is the only context.\n - **Grid cards** (multiple side-by-side): keep border for grid separation, drop bg. The card becomes a transparent container with a hairline outline.\n\n ```css\n .my-page .section.alt .my-expert,\n .my-page .section.alt .my-expert::before { background: transparent; border: 0; padding: 0; display: none }\n .my-page .section.alt .my-grid-card { background: transparent } /* grid cards keep border */\n ```\n\nMental model: bg shifts twice before you have a problem — body → section → card. Three shifts feels muddled.\n\n### Gradient-clipped headings need extra line-height for descenders\n\nWhen using `-webkit-background-clip: text` + `display: inline-block` to render a gradient-filled heading, the inline-block box is sized by `line-height`. With `line-height: 1` (a common \"tight\" value for display headings) the descenders of g/j/y/p get clipped.\n\n**Rule:** gradient-clipped headings use `line-height: 1.1` or higher, plus `padding-bottom: 0.05em` for belt-and-braces:\n\n```css\n.gradient-h1 {\n display: inline-block;\n background: var(--gradient-brand);\n -webkit-background-clip: text;\n background-clip: text;\n color: transparent;\n line-height: 1.12;\n padding-bottom: 0.05em;\n}\n```\n\n### Hero copy is not body copy\n\nWhen porting a page, identify the H1 + tagline pair and leave the body intro inside the body. Don't lift a body sentence up into the hero unless the source has it twice.\n\nRule: hero gets at most **H1 + one tagline**. Body intro stays in the body. Repeating the same sentence in both places looks accidental.\n\n### Footer architecture: navigate by domain, not by content type\n\nDefault footer columns should mirror the user's mental model of the BUSINESS, not the technical content shapes. Anti-pattern: separate \"Podcast / Articles / Events / Offers\" columns that just list content categories.\n\nBetter default:\n- **Områden / Domains** — subject domains, what the user wants help with\n- **Företaget / Company** — about / services / legal, meta-information about the org\n\nReserve a third column only when there's a genuinely different surface (locations, languages, partner pages). Don't pad the footer with content-type columns — navigation to those happens via top nav + topic pages.\n\n## Step 7 — Preview\n\n```\nget_preview_link\n```\n\nOpen in browser. Check:\n- Colors render as intended (not \"undefined\" or missing)\n- Fonts load (Google Fonts link is in `<head>`)\n- Nav text is readable against header background\n- Body text has sufficient contrast\n\n## Pitfalls\n\n- **Don't set colors without checking the header contrast.** If `primary`\n is light, white nav text becomes unreadable. Either darken `primary` or\n make the header use `secondary`.\n- **Custom_css is global.** Rules here apply to every page. Keep it to\n `:root {}` token additions and truly global utilities. Page-specific\n styles go in the page's HTML `<style>` block.\n- **Google Fonts load time.** Two different font families is fine; three\n adds measurable LCP impact. Stick to two families with variable-font\n versions when possible.\n- **Dark themes need dark surface too.** Setting `background: #0f0f0f`\n but leaving `surface: #f8fafc` (white) breaks every card/input. Always\n update all 7 tokens as a set.\n- **The renderer's `.page-content` layout shell (html-mode only).** The\n renderer wraps `html_content` in `<main class=\"page-content\">` with\n constrained `max-width` and default typography. The typography defaults\n now sit inside `:where()` so they have specificity 0 — a customer's\n class rules trivially win. The layout shell (width + padding) is still\n at normal specificity by design: it's what gives a brand-new page\n reasonable margins out of the box. If a section needs to escape the\n shell (full-bleed bg, full-width hero), apply the negative-margin\n pattern shown in Step 4b. Don't fight the shell with `overflow-x`\n hacks. Block-mode pages don't have the width problem — sections are\n natively full-bleed there.\n- **The shell's global `img` rule leaks into custom figures.** Both modes\n apply `:where(.page-content) img { margin: …; border-radius: … }`. A\n hand-built image card (rounded clipping wrapper around an `<img>`) gets\n phantom margins inside the wrapper — visible as white bands above and\n below the photo. Zero it explicitly in your figure CSS:\n `.my-figure img { margin: 0; border-radius: 0 }`.\n",
7
+ "tr-blog": "---\nname: tr-blog\ndescription: Use when the user wants to set up a blog, news section, podcast feed, or any time-ordered article-style content on a Typeroll site. Triggers on \"add a blog\", \"set up news\", \"article section\", \"create posts\", \"podcast\", \"inlägg\", \"nyheter\", \"avsnitt\", or any feed-of-dated-entries pattern.\n---\n\n# Set up a blog / news section\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nA blog in Typeroll is a **collection with `item_template_html` + `route_template`**. Every published item materialises as its own static page at build time — there is **no need to call `create_page` per article**. The detail design lives once in `item_template_html`; the listing lives once in a page with a `<!-- typeroll:listing -->` marker that `regenerate_collection_listing` refreshes.\n\nIf you find yourself about to create 20 pages for 20 articles, stop — you're using the old pattern. The recipe below is the right one.\n\n## Preconditions\n\n- Site exists with working header/footer.\n- Collection name picked (`blog`, `news`, `artiklar`, `podcast`, `avsnitt`).\n- URL structure picked: `/blog/{slug}`, `/news/{slug}`, `/podd/{slug}`. Changing later renames every URL.\n\n## Recipe\n\n### 1. Create the collection with detail template baked in\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Artikel\",\n \"label_plural\": \"Artiklar\",\n \"icon\": \"📝\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"item_template_html\": \"<article class=\\\"post\\\">\\n <header class=\\\"post__header\\\">\\n <time>{{date}}</time>\\n <h1>{{title}}</h1>\\n {{#author}}<p class=\\\"byline\\\">av {{author}}</p>{{/author}}\\n </header>\\n {{#image}}<img class=\\\"post__hero\\\" src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\" />{{/image}}\\n <div class=\\\"post__body\\\">{{{body}}}</div>\\n</article>\\n<style>\\n.post{max-width:42rem;margin:3rem auto;padding:0 1rem}\\n.post__header time{color:var(--color-text-light);font-size:0.85rem}\\n.post__header h1{font-family:var(--font-heading);font-size:2.25rem;margin:0.25rem 0}\\n.byline{color:var(--color-text-light);font-size:0.9rem}\\n.post__hero{width:100%;aspect-ratio:16/9;object-fit:cover;border-radius:0.5rem;margin:2rem 0}\\n.post__body{font-size:1.05rem;line-height:1.7}\\n.post__body h2{font-family:var(--font-heading);margin-top:2rem}\\n.post__body p{margin-bottom:1.25rem}\\n</style>\",\n \"fields\": [\n {\"name\": \"title\", \"type\": \"text\", \"label\": \"Rubrik\", \"required\": true},\n {\"name\": \"slug\", \"type\": \"text\", \"label\": \"URL-slug\", \"required\": true},\n {\"name\": \"date\", \"type\": \"date\", \"label\": \"Datum\", \"required\": true},\n {\"name\": \"author\", \"type\": \"text\", \"label\": \"Författare\"},\n {\"name\": \"excerpt\", \"type\": \"textarea\", \"label\": \"Ingress\"},\n {\"name\": \"body\", \"type\": \"richtext\", \"label\": \"Brödtext\"},\n {\"name\": \"image\", \"type\": \"image\", \"label\": \"Omslagsbild\"}\n ]\n}\n```\n\n**About `item_template_html`:**\n- `{{field}}` HTML-escapes the value (use for plain text).\n- `{{{field}}}` leaves it raw (use for `body` and any richtext).\n- `{{#field}}...{{/field}}` is a conditional — render the block only if the field is truthy. Useful for optional images, authors, etc.\n- **No loops, no nested conditionals.** If you need either, pre-render the HTML in a field on the item itself (see tr-collection-template for patterns).\n\n**Field name rule:** ASCII only, lowercase, `[a-z][a-z0-9_-]*`. `ä→a`, `ö→o`, `å→a` for the `name`; the `label` can be anything.\n\n### 2. Seed with real content\n\n```\ncreate_collection_item collection=\"blog\" status=\"published\" fields={\n \"title\": \"Vår designfilosofi\",\n \"slug\": \"var-designfilosofi\",\n \"date\": \"2025-05-15\",\n \"author\": \"Anna Lindström\",\n \"excerpt\": \"Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.\",\n \"body\": \"<p>Lång brödtext här...</p><h2>En underrubrik</h2><p>Mer text...</p>\",\n \"image\": \"https://cdn.typeroll.com/...\"\n}\n```\n\nIf `image` is a URL from elsewhere, upload it first via `upload_media_from_url` and use the returned CDN URL.\n\nEach published item with this collection's `route_template` automatically becomes `/blog/{slug}` at deploy time — you do **not** need to call `create_page`.\n\n### 3. Build the listing page (once)\n\nCreate a single page that hosts the listing. The HTML between the `typeroll:listing` markers gets regenerated whenever the collection changes:\n\n```\ncreate_page title=\"Artiklar\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section class=\\\"blog-listing\\\">\n <div class=\\\"container\\\">\n <h1 class=\\\"section-title\\\">Artiklar</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n </div>\n</section>\n<style>\n.blog-listing{padding:4rem 0}\n.blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem;margin-top:2rem}\n.blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}\n.blog-card a{text-decoration:none;display:block;color:var(--color-text)}\n.blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}\n.blog-card__body{padding:1.5rem}\n.blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}\n.blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}\n.blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}\n.blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}\n</style>\"\n```\n\n### 4. Populate the listing (and re-run after every change)\n\n```\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\">\n <a href=\\\"{{url}}\\\">\n {{#image}}<img src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\">{{/image}}\n <div class=\\\"blog-card__body\\\">\n <time class=\\\"blog-card__date\\\">{{date}}</time>\n <h2 class=\\\"blog-card__title\\\">{{title}}</h2>\n <p class=\\\"blog-card__excerpt\\\">{{excerpt}}</p>\n <span class=\\\"blog-card__cta\\\">Läs mer →</span>\n </div>\n </a>\n </article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\n`{{url}}` resolves through the collection's `route_template`. Only the content between the markers is replaced; everything else on the page stays put. Re-run this whenever items are added, edited, or unpublished.\n\n### 5. Update the header partial to link to the listing\n\n```\nread_partial partial_id=\"header\"\nreplace_partial partial_id=\"header\" html_content=\"<updated with /blog link>\"\n```\n\n### 6. Preview a single article\n\n```\nget_preview_link collection_name=\"blog\" item_id=\"<id>\"\n```\n\nThe returned URL renders the item through `item_template_html` exactly as it'll appear in production.\n\n### 7. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nThe build produces one HTML file per published article at `/blog/<slug>` plus the listing at `/blog`, and includes them all in `sitemap.xml`.\n\n## Adding a new article later\n\n```\ncreate_collection_item collection=\"blog\" status=\"published\" fields={ ... }\nregenerate_collection_listing collection=\"blog\" page_id=\"blog\" item_template=\"...\" wrap_open=\"...\" wrap_close=\"...\"\ntrigger_deploy\n```\n\nThree calls. No per-article `create_page`. No HTML diffing by hand.\n\n## Pitfalls\n\n- **Don't fall back to \"one page per article\".** That was the pre-`item_template_html` pattern. It's strictly worse now: design changes mean editing N pages, you lose `{{url}}` resolution in listings, sitemap doesn't include items, previews can't surface a per-item URL — and the API will reject your attempt anyway. `create_page` rejects slugs containing slashes (\"Invalid slug … slugs must not contain slashes\"), so `slug: \"blog/foo\"` doesn't even get through. The collection's `route_template` is the only path to nested URLs.\n- **Slugs must be unique within the collection.** `regenerate_collection_listing` will silently drop items where `slug` is missing; the listing count will be lower than the item count.\n- **Don't use non-ASCII field names.** `datum` not `Datum`; `forfattare` not `författare` in the `name`. The `label` is free-form.\n- **Listing goes stale if you forget step 4.** Every item change needs `regenerate_collection_listing`. Add it to your mental checklist after every `create/update_collection_item`.\n- **`{{#field}}...{{/field}}` only checks truthiness.** Empty string and the field being absent both count as falsy. If you need \"render this block when `published_at` is later than today\", do it in the data step — set a flag field.\n- **Template too clever.** Mustache substitution has no loops or arithmetic. For an article with chapter timestamps, multiple authors, a guest with nested links — pre-render the HTML into a single field at `create_collection_item` time. See `tr-collection-template` for concrete patterns.\n\n## When you want a page that ISN'T a collection item\n\nA normal `create_page` is still right for:\n- The blog's about/contact pages.\n- Editorial standalone features.\n- Anything that doesn't fit the \"list of dated entries\" mould.\n\nJust don't use `create_page` *for the entries themselves*.\n",
8
+ "tr-brand": "---\nname: tr-brand\ndescription: Use when the user asks to create a brand identity, design system, or visual style for a site. Triggers on \"create a brand\", \"design the look\", \"choose colors\", \"pick fonts\", \"make it look like [reference]\", or \"rebrand the site\". Produces a cohesive palette, typography scale, and CSS custom properties applied to an existing site.\n---\n\n# Design a brand identity for a Typeroll site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThis skill turns a brief (or a reference URL/screenshot) into a complete\nvisual design system applied to the site's settings and partials.\n\n## Preconditions\n\n- Site exists and MCP is configured.\n- You have at least one of: industry, mood words, reference URL, existing\n logo colors, or competitor sites to contrast with.\n\n## Step 1 — Gather context\n\nAsk (or infer from the brief):\n\n1. **Industry + audience.** Law firm → formal, trust. Café → warm, approachable.\n Tech startup → clean, modern. Interior design → refined, editorial.\n2. **Mood words.** 3–5 adjectives the brand should feel: \"minimal, Nordic,\n calm\" or \"bold, energetic, playful\".\n3. **Reference.** A URL, a screenshot, or a competitor they like (and what\n they want to be different from it).\n4. **Must-keep.** Existing logo color? Legal industry color conventions?\n\nIf the user provided a URL, fetch it and note the dominant colors,\ntypeface categories, and layout density.\n\n## Step 2 — Build the palette\n\nA Typeroll site uses 7 color tokens:\n\n| Token | Role | Design rule |\n|---|---|---|\n| `primary` | Brand identity. CTA buttons, active nav, links. | High contrast on `background`. |\n| `secondary` | Header, footer, darker sections. | Darker or more neutral than primary. |\n| `accent` | Highlights, price tags, badges, hover states. | High-energy complement. |\n| `background` | Page background. | Near-white for light themes, near-black for dark. |\n| `surface` | Cards, input boxes, code blocks. | Slightly off from `background`. |\n| `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |\n| `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |\n\n**Palette recipes by mood:**\n\n*Nordic / minimal:*\n```\nprimary: #1f2a30 secondary: #142027 accent: #c9b89a\nbackground: #faf8f4 surface: #f2ede5 text: #1f2a30 text_light: #7a7265\n```\n\n*Warm / artisan:*\n```\nprimary: #3d2b1f secondary: #2a1d14 accent: #c8860a\nbackground: #fdf6ee surface: #f7ede0 text: #1a1008 text_light: #8a7060\n```\n\n*Modern / tech:*\n```\nprimary: #2563eb secondary: #1e293b accent: #f59e0b\nbackground: #ffffff surface: #f8fafc text: #0f172a text_light: #64748b\n```\n\n*Editorial / dark:*\n```\nprimary: #e2c08d secondary: #0f0f0f accent: #e2c08d\nbackground: #0f0f0f surface: #1a1a1a text: #f5f5f0 text_light: #a0a090\n```\n\nCheck WCAG contrast ratios mentally: text on background must be ≥4.5:1.\nThe online tool `https://webaim.org/resources/contrastchecker/` is useful\nbut not accessible during a tool call — reason about perceived contrast\ninstead (light grey on white = bad; dark grey on white = fine).\n\n## Step 3 — Choose typefaces\n\nPick from high-quality Google Fonts pairings:\n\n| Heading | Body | Mood |\n|---|---|---|\n| Cormorant Garamond | Raleway | Luxury, editorial |\n| Playfair Display | Source Sans 3 | Classic, readable |\n| DM Serif Display | DM Sans | Contemporary, clean |\n| Fraunces | Mulish | Artisan, craft |\n| Syne | Inter | Bold, modern |\n| Plus Jakarta Sans | Plus Jakarta Sans | Clean, versatile |\n| Libre Baskerville | Libre Franklin | Traditional, trustworthy |\n\nSame font for heading and body is fine if it has enough weight variation\n(Inter at 700 + 400 works well).\n\n`size_base` should be 16 for most sites; 17–18 for text-heavy editorial\nsites; 15 for dense dashboards.\n\n## Step 4 — Apply to the site\n\nOne call sets everything:\n\n```\nupdate_site_settings {\n \"colors\": { ...all 7 tokens },\n \"fonts\": { \"heading\": \"...\", \"body\": \"...\", \"size_base\": 16 },\n \"custom_css\": \"/* optional: utility classes or @keyframes */\"\n}\n```\n\nRead back to confirm: `read_site_settings`.\n\n**Inside a redesign branch, scope the brand to the branch** — pass\n`version=\"<branch>\"` on `update_site_settings` (and `read_site_settings`).\nColors / fonts / logo / custom_css then live on the branch (copy-on-write,\nchain-fallback to main for anything you don't override) and don't touch the\nlive site until you `merge_branch`. This is the correct way to rebrand on a\nbranch — don't hack the palette into a `:root{}` override in the header\npartial just to keep it off live; that's no longer necessary.\n\n### Site icons — always propose them, never leave them empty\n\nEvery site gets a favicon + apple touch icon as part of brand setup:\n\n1. **Brand assets exist** (favicon-*.png, app icon, symbol): upload the\n right sizes via `upload_media_inline` (favicon: 32–64px PNG or SVG;\n apple touch icon: 180×180 PNG) and set BOTH in one call:\n `update_site_settings { \"favicon\": \"<url>\", \"apple_touch_icon\": \"<url>\" }`.\n2. **No icon assets:** derive a proposal instead of skipping — crop the\n logo's symbol to a square and resize locally (`sips -z 180 180 in.png\n --out icon-180.png` on macOS, or ImageMagick), or generate a simple\n icon candidate with the imagegen lab (see `tr-imagegen`; respect the\n style profile, no text). Upload, set, and tell the user it's a\n proposal they can swap.\n\nA site shipping with the browser's default globe icon is a build gap —\ntreat icons like the logo: part of done.\n\n## Step 5 — Update partials to use the new palette\n\nPartials that hardcoded hex colors need updating. Fetch the header:\n\n```\nread_partial partial_id=\"header\"\n```\n\nIf it has hardcoded colors, replace them with CSS variable references\n(`var(--color-primary)`) and call `replace_partial`:\n\n```\nreplace_partial partial_id=\"header\" html_content=\"<updated HTML>\"\n```\n\nSame for footer.\n\n## Step 6 — Custom CSS for advanced tokens (optional)\n\nIf the brand needs things beyond the 7 base tokens — e.g. a gradient,\na special border radius, or a branded highlight color — add them via\n`custom_css`:\n\n```css\n:root {\n --brand-gradient: linear-gradient(135deg, var(--color-primary), var(--color-accent));\n --radius-brand: 2px; /* sharp corners for formal brands */\n --letter-spacing-display: -0.03em; /* tight tracking for display headings */\n}\n```\n\nThen reference `var(--brand-gradient)` etc. in page HTML and partials.\n\n## Step 4b — Section + layout design defaults\n\nThese are non-negotiable defaults the rest of the platform skills inherit (`tr-new-site`, `tr-directory`, `tr-collection-template`). Apply them on every page that has visible sections — they're battle-tested across real customer migrations.\n\n### One signal per section boundary\n\nUse **either** a background-color shift **or** a horizontal divider line at a section transition — never both stacked. They serve the same purpose; stacking them looks busy.\n\n- Default: alternating `.section` / `.section.alt` with a bg shift is enough.\n- A standalone divider line (gradient/keyline) is reserved for the hero → body boundary, where the bg already shifts.\n\n### Sections are full-bleed; content is container-width\n\nThe section element ALWAYS spans the full viewport (its bg, border, decorative line). Content inside is constrained to a readable column.\n\n**Block-mode pages (the default):** this is native. Top-level\n`core/section` blocks are full-bleed out of the box — set `background`\non the section and it runs edge-to-edge, meeting the header with zero\ngap; the section's `width` field (narrow/normal/wide/full) constrains\nthe content column. **NEVER add 100vw negative-margin hacks on block\npages** — they double-bleed and break. Anchor ids / custom classes on\nsections are safe from template_capabilities_version ≥ 0.15.3 (older\nversions wrapped the section in a div and silently killed full-bleed —\nthere, put the anchor on a block inside the section).\n\n**HTML-mode pages (`html_content`) only:** the renderer wraps the body\nin `<main class=\"page-content\">` with `max-width: var(--container-medium)`\n— a section's bg-color rule alone gives a \"1080px-wide stripe in the\nmiddle of the page\", which is wrong. Every section that has a bg/border\nmust apply the negative-margin escape:\n\n```css\n.my-page .section {\n position: relative;\n margin-left: calc(50% - 50vw);\n margin-right: calc(50% - 50vw);\n width: 100vw;\n padding: 74px 0;\n}\n.my-page .section.alt { background: #fff }\n```\n\nThe first section (hero) also wants `margin-top: -32px` to cancel `.page-content`'s top padding. Do NOT wrap the page in `overflow-x: clip` — it cancels the bleed.\n\n### Cards sit directly on the section bg — never on a matching bg\n\nA card with a white bg inside a white-bg section creates a redundant \"white plate on white\" effect. Two enforcement rules:\n\n1. Cards on the default page bg (`var(--color-background)`) can use `background: #fff` + border. ✓\n2. Cards on `.section.alt` (which has `background: #fff`) must drop their own bg:\n - **Single-card section** (one card in a section): drop all chrome (bg, border, accent line). Just content; the section's bg is the only context.\n - **Grid cards** (multiple side-by-side): keep border for grid separation, drop bg. The card becomes a transparent container with a hairline outline.\n\n ```css\n .my-page .section.alt .my-expert,\n .my-page .section.alt .my-expert::before { background: transparent; border: 0; padding: 0; display: none }\n .my-page .section.alt .my-grid-card { background: transparent } /* grid cards keep border */\n ```\n\nMental model: bg shifts twice before you have a problem — body → section → card. Three shifts feels muddled.\n\n### Gradient-clipped headings need extra line-height for descenders\n\nWhen using `-webkit-background-clip: text` + `display: inline-block` to render a gradient-filled heading, the inline-block box is sized by `line-height`. With `line-height: 1` (a common \"tight\" value for display headings) the descenders of g/j/y/p get clipped.\n\n**Rule:** gradient-clipped headings use `line-height: 1.1` or higher, plus `padding-bottom: 0.05em` for belt-and-braces:\n\n```css\n.gradient-h1 {\n display: inline-block;\n background: var(--gradient-brand);\n -webkit-background-clip: text;\n background-clip: text;\n color: transparent;\n line-height: 1.12;\n padding-bottom: 0.05em;\n}\n```\n\n### Hero copy is not body copy\n\nWhen porting a page, identify the H1 + tagline pair and leave the body intro inside the body. Don't lift a body sentence up into the hero unless the source has it twice.\n\nRule: hero gets at most **H1 + one tagline**. Body intro stays in the body. Repeating the same sentence in both places looks accidental.\n\n### Footer architecture: navigate by domain, not by content type\n\nDefault footer columns should mirror the user's mental model of the BUSINESS, not the technical content shapes. Anti-pattern: separate \"Podcast / Articles / Events / Offers\" columns that just list content categories.\n\nBetter default:\n- **Områden / Domains** — subject domains, what the user wants help with\n- **Företaget / Company** — about / services / legal, meta-information about the org\n\nReserve a third column only when there's a genuinely different surface (locations, languages, partner pages). Don't pad the footer with content-type columns — navigation to those happens via top nav + topic pages.\n\n## Step 7 — Preview\n\n```\nget_preview_link\n```\n\nOpen in browser. Check:\n- Colors render as intended (not \"undefined\" or missing)\n- Fonts load (Google Fonts link is in `<head>`)\n- Nav text is readable against header background\n- Body text has sufficient contrast\n\n## Pitfalls\n\n- **Don't set colors without checking the header contrast.** If `primary`\n is light, white nav text becomes unreadable. Either darken `primary` or\n make the header use `secondary`.\n- **Custom_css is global.** Rules here apply to every page. Keep it to\n `:root {}` token additions and truly global utilities. Page-specific\n styles go in the page's HTML `<style>` block.\n- **Google Fonts load time.** Two different font families is fine; three\n adds measurable LCP impact. Stick to two families with variable-font\n versions when possible.\n- **Dark themes need dark surface too.** Setting `background: #0f0f0f`\n but leaving `surface: #f8fafc` (white) breaks every card/input. Always\n update all 7 tokens as a set.\n- **The renderer's `.page-content` layout shell (html-mode only).** The\n renderer wraps `html_content` in `<main class=\"page-content\">` with\n constrained `max-width` and default typography. The typography defaults\n now sit inside `:where()` so they have specificity 0 — a customer's\n class rules trivially win. The layout shell (width + padding) is still\n at normal specificity by design: it's what gives a brand-new page\n reasonable margins out of the box. If a section needs to escape the\n shell (full-bleed bg, full-width hero), apply the negative-margin\n pattern shown in Step 4b. Don't fight the shell with `overflow-x`\n hacks. Block-mode pages don't have the width problem — sections are\n natively full-bleed there.\n- **The shell's global `img` rule leaks into custom figures.** Both modes\n apply `:where(.page-content) img { margin: …; border-radius: … }`. A\n hand-built image card (rounded clipping wrapper around an `<img>`) gets\n phantom margins inside the wrapper — visible as white bands above and\n below the photo. Zero it explicitly in your figure CSS:\n `.my-figure img { margin: 0; border-radius: 0 }`.\n",
9
9
  "tr-collection-template": "---\nname: tr-collection-template\ndescription: Use when building a rich per-item detail page for a Typeroll collection — podcast episodes with audio players and chapter timestamps, case studies with guest cards and metric tiles, products with image galleries and spec tables, anything where the detail template would normally want loops or nested data. Covers the \"pre-render into a field\" pattern that gets you past the template's no-loops-no-conditionals limit.\n---\n\n# Rich detail templates for collections\n\n`item_template_html` uses lightweight Mustache substitution:\n\n- `{{field}}` — HTML-escaped value\n- `{{{field}}}` — raw value (for richtext / pre-rendered HTML)\n- `{{#field}}…{{/field}}` — conditional, render block when field is truthy\n- `{{url}}` — only meaningful in `regenerate_collection_listing`'s `item_template` (resolves through `route_template`)\n\n**No loops, no nested field access, no arithmetic.** `{{chapters[0].title}}` doesn't work. `{{#chapters}}{{title}}{{/chapters}}` doesn't either — the section syntax is truthiness-only, not iteration.\n\nThe pattern that gets you everywhere: **pre-render the HTML into a single string field on the item itself.** The agent (you) does the loop in JavaScript/Python during data prep, then writes the resulting HTML into a richtext field like `chapters_html` or `gallery_html`. The template renders it raw with `{{{chapters_html}}}`.\n\nThis skill catalogues the patterns we hit most often, with copy-paste recipes.\n\n## Pattern 1 — Audio player + chapter list (podcast episodes)\n\n**Data shape going in:**\n\n```json\n{\n \"title\": \"Avsnitt 17 — Designsystem på riktigt\",\n \"slug\": \"17-designsystem-pa-riktigt\",\n \"date\": \"2025-05-15\",\n \"audio_url\": \"https://cdn.example.com/avsnitt-17.mp3\",\n \"duration_min\": 42,\n \"chapters\": [\n { \"time_seconds\": 0, \"title\": \"Intro\" },\n { \"time_seconds\": 132, \"title\": \"Vad är ett designsystem?\" },\n { \"time_seconds\": 845, \"title\": \"Tokens vs. komponenter\" },\n { \"time_seconds\": 1820, \"title\": \"Vanliga fällor\" }\n ]\n}\n```\n\n**Pre-render `chapters_html` before calling `create_collection_item`:**\n\n```js\nconst formatTime = (s) =>\n s < 3600\n ? `${Math.floor(s/60)}:${String(s%60).padStart(2,'0')}`\n : `${Math.floor(s/3600)}:${String(Math.floor(s%3600/60)).padStart(2,'0')}:${String(s%60).padStart(2,'0')}`;\n\nconst chaptersHtml = `\n<ol class=\"chapters\">\n ${item.chapters.map(c => `\n <li>\n <button type=\"button\" data-jump-to=\"${c.time_seconds}\">\n <span class=\"chapters__time\">${formatTime(c.time_seconds)}</span>\n <span class=\"chapters__title\">${escapeHtml(c.title)}</span>\n </button>\n </li>\n `).join('')}\n</ol>`;\n```\n\n**Schema (note `chapters_html: richtext` — it carries HTML):**\n\n```\ncreate_collection {\n \"name\": \"avsnitt\",\n \"label_singular\": \"Avsnitt\",\n \"label_plural\": \"Avsnitt\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/podd/{slug}\",\n \"fields\": [\n {\"name\":\"title\", \"type\":\"text\", \"required\":true},\n {\"name\":\"slug\", \"type\":\"text\", \"required\":true},\n {\"name\":\"date\", \"type\":\"date\", \"required\":true},\n {\"name\":\"audio_url\", \"type\":\"text\", \"required\":true},\n {\"name\":\"duration_min\", \"type\":\"number\"},\n {\"name\":\"excerpt\", \"type\":\"textarea\"},\n {\"name\":\"body\", \"type\":\"richtext\"},\n {\"name\":\"chapters_html\", \"type\":\"richtext\", \"label\":\"Kapitellista (genereras)\"}\n ],\n \"item_template_html\": \"<article class=\\\"episode\\\">\\n <header class=\\\"episode__hero\\\">\\n <p class=\\\"episode__date\\\">{{date}} · {{duration_min}} min</p>\\n <h1>{{title}}</h1>\\n <p class=\\\"episode__excerpt\\\">{{excerpt}}</p>\\n </header>\\n <div class=\\\"episode__player\\\">\\n <audio controls preload=\\\"metadata\\\" src=\\\"{{audio_url}}\\\"></audio>\\n </div>\\n {{#chapters_html}}<section class=\\\"episode__chapters\\\"><h2>Kapitel</h2>{{{chapters_html}}}</section>{{/chapters_html}}\\n <section class=\\\"episode__notes\\\">{{{body}}}</section>\\n <script>\\n document.querySelectorAll('[data-jump-to]').forEach(b => {\\n b.addEventListener('click', () => {\\n const a = document.querySelector('audio');\\n if (a) { a.currentTime = Number(b.dataset.jumpTo); a.play(); }\\n });\\n });\\n </script>\\n <style>\\n .episode{max-width:42rem;margin:3rem auto;padding:0 1rem}\\n .episode__hero{background:linear-gradient(135deg,var(--color-primary),var(--color-accent));color:#fff;padding:3rem 2rem;border-radius:1rem;margin-bottom:2rem}\\n .episode__date{opacity:0.85;font-size:0.85rem}\\n .episode__hero h1{font-family:var(--font-heading);font-size:2rem;margin:0.5rem 0}\\n .episode__player audio{width:100%}\\n .chapters{list-style:none;padding:0;margin:1.5rem 0}\\n .chapters li{margin:0.25rem 0}\\n .chapters button{display:flex;gap:1rem;width:100%;background:transparent;border:0;padding:0.5rem 0.75rem;cursor:pointer;text-align:left;border-radius:0.375rem;font:inherit;color:inherit}\\n .chapters button:hover{background:var(--color-surface)}\\n .chapters__time{font-variant-numeric:tabular-nums;color:var(--color-text-light);min-width:4ch}\\n </style>\\n</article>\"\n}\n```\n\n**Create the item with both raw chapters AND the pre-rendered HTML:**\n\n```\ncreate_collection_item collection=\"avsnitt\" status=\"published\" fields={\n \"title\": \"Avsnitt 17 — Designsystem på riktigt\",\n \"slug\": \"17-designsystem-pa-riktigt\",\n \"date\": \"2025-05-15\",\n \"audio_url\": \"https://cdn.example.com/avsnitt-17.mp3\",\n \"duration_min\": 42,\n \"excerpt\": \"Vi pratar med...\",\n \"body\": \"<p>...</p>\",\n \"chapters_html\": \"<ol class=\\\"chapters\\\">...</ol>\"\n}\n```\n\nThe raw `chapters` array doesn't need to be stored unless you have a use for it (e.g. regenerating the HTML later from a structured source). If you do want it for round-trip editing, add a `chapters_json: textarea` field and stringify the array into it.\n\n## Pattern 2 — Guest card with nested fields\n\nEach episode features a guest with a name, role, photo, and external links. Mustache can't reach into nested objects, so flatten OR pre-render.\n\n**Option A: Flatten into prefixed fields (preferable when there's ≤1 guest):**\n\n```\nfields: [\n ...,\n {\"name\":\"guest_name\", \"type\":\"text\"},\n {\"name\":\"guest_role\", \"type\":\"text\"},\n {\"name\":\"guest_photo\", \"type\":\"image\"},\n {\"name\":\"guest_bio\", \"type\":\"textarea\"},\n {\"name\":\"guest_linkedin\", \"type\":\"text\"},\n {\"name\":\"guest_website\", \"type\":\"text\"}\n]\n```\n\nIn the template:\n\n```html\n{{#guest_name}}\n<aside class=\"guest\">\n {{#guest_photo}}<img src=\"{{guest_photo}}\" alt=\"{{guest_name}}\">{{/guest_photo}}\n <div>\n <h3>{{guest_name}}</h3>\n <p class=\"guest__role\">{{guest_role}}</p>\n <p>{{guest_bio}}</p>\n <p class=\"guest__links\">\n {{#guest_linkedin}}<a href=\"{{guest_linkedin}}\">LinkedIn</a>{{/guest_linkedin}}\n {{#guest_website}}<a href=\"{{guest_website}}\">Webbplats</a>{{/guest_website}}\n </p>\n </div>\n</aside>\n{{/guest_name}}\n```\n\n**Option B: Pre-render `guest_html` (when there are multiple guests or arbitrary depth):**\n\n```js\nconst guestHtml = item.guests.map(g => `\n <article class=\"guest\">\n ${g.photo ? `<img src=\"${escapeHtml(g.photo)}\" alt=\"${escapeHtml(g.name)}\">` : ''}\n <div>\n <h3>${escapeHtml(g.name)}</h3>\n <p class=\"guest__role\">${escapeHtml(g.role)}</p>\n ${g.links.map(l => `<a href=\"${escapeHtml(l.url)}\">${escapeHtml(l.label)}</a>`).join(' · ')}\n </div>\n </article>\n`).join('');\n```\n\nThen `{{{guests_html}}}` in the template.\n\n## Pattern 3 — Gradient hero with computed colours\n\nThe hero needs a colour pair derived from a single brand colour the user picked per item. The template can't compute — pre-compute and pass as fields:\n\n```js\nfunction shade(hex, amount) { /* lighten/darken */ }\n\nconst item = {\n ...,\n hero_from: rawColor,\n hero_to: shade(rawColor, -0.2),\n};\n```\n\nTemplate:\n\n```html\n<header class=\"hero\" style=\"background:linear-gradient(135deg, {{hero_from}}, {{hero_to}})\">\n <h1>{{title}}</h1>\n</header>\n```\n\nInline style with two substituted hex strings — works because the `{{}}` substitutions sit inside a CSS value, not as a CSS variable name. (Don't do this with user-supplied colours that haven't been validated — a malicious item could break out of the style attribute. For agent-curated colours this is fine.)\n\n## Pattern 4 — Image gallery with thumbnails\n\nSame drill. The agent renders the gallery HTML when shaping the item:\n\n```js\nconst galleryHtml = `\n<div class=\"gallery\">\n ${item.images.map((img, i) => `\n <a href=\"${escapeHtml(img.full)}\" class=\"gallery__item\">\n <img src=\"${escapeHtml(img.thumb)}\" alt=\"${escapeHtml(img.alt || `Bild ${i+1}`)}\" loading=\"lazy\">\n </a>\n `).join('')}\n</div>`;\n```\n\nSchema gains `gallery_html: richtext`. Template renders `{{{gallery_html}}}`.\n\nFor a lightbox you can either inline a tiny vanilla JS handler in the item_template_html (works once per page load) or `tr-images` the gallery into a reusable partial.\n\n## Pattern 5 — Spec table (for products, services, etc.)\n\nFor a fixed set of spec fields (price, dimensions, in-stock, lead time), just add the fields explicitly:\n\n```\nfields: [\n ...,\n {\"name\":\"price_sek\", \"type\":\"number\"},\n {\"name\":\"weight_g\", \"type\":\"number\"},\n {\"name\":\"in_stock\", \"type\":\"boolean\"},\n {\"name\":\"lead_days\", \"type\":\"number\"}\n]\n```\n\n```html\n<dl class=\"specs\">\n {{#price_sek}}<dt>Pris</dt><dd>{{price_sek}} kr</dd>{{/price_sek}}\n {{#weight_g}}<dt>Vikt</dt><dd>{{weight_g}} g</dd>{{/weight_g}}\n <dt>Lagerstatus</dt><dd>{{#in_stock}}I lager{{/in_stock}}{{^in_stock}}Slut{{/in_stock}}</dd>\n {{#lead_days}}<dt>Leveranstid</dt><dd>{{lead_days}} dagar</dd>{{/lead_days}}\n</dl>\n```\n\n(`{{^field}}…{{/field}}` is the inverse of `{{#field}}` — render when falsy.)\n\nFor variable specs (different products have different attributes), fall back to a pre-rendered `specs_html` field.\n\n## Pre-rendering helpers — minimum viable\n\nEvery pre-render needs `escapeHtml`. Put this at the top of your data-prep script:\n\n```js\nconst escapeHtml = (s) =>\n String(s ?? '')\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&#39;');\n```\n\nSkip it only when you're certain the value can't carry user-supplied content (your own constants are fine; anything from a scrape, the user, or a model output goes through `escapeHtml`).\n\n## When to flatten vs pre-render\n\nRough guideline:\n\n| Situation | Approach |\n|---|---|\n| 1–N optional related fields, fixed shape | Flatten into prefixed fields (`guest_name`, `guest_role`, …) and use `{{#field}}` conditionals |\n| List of items with internal structure (chapters, gallery, related-links) | Pre-render to a single `*_html` field |\n| Computed values (formatted dates, derived colours, totals) | Pre-compute as a sibling field, substitute with `{{}}` |\n| Conditional sections based on multiple fields (\"show this when status=published AND has_video\") | Pre-compute a boolean field; conditional in the template |\n| Genuinely dynamic content that changes per visitor | Doesn't fit — the static template renders once at build. Move to client-side JS in the template body, or rethink the page. |\n\n## Pitfalls\n\n- **Forgetting `{{{ }}}` for pre-rendered HTML.** `{{chapters_html}}` (double braces) HTML-escapes the angle brackets and shows source code. Must be triple braces.\n- **Mutating a published item's schema.** Removing or renaming a field that the template references silently produces empty sections. Keep templates in sync with schema changes.\n- **Pre-rendered HTML drifts when you change the visual design.** The HTML for `chapters_html` was generated against the design as it was on import day. If you redo the look later, you need to re-prep + re-write every item's pre-rendered field, not just the template. Consider keeping the raw data (`chapters_json: textarea` with the original array stringified) so you can regenerate.\n- **Inline `<script>` in `item_template_html` runs once per page.** That's fine for self-contained per-page widgets (the audio chapter-jumper above). If two collections need the same widget, factor it into a partial that both `item_template_html`s `<x-include>`.\n- **Don't put credentials in pre-rendered HTML.** API keys, signed tokens — they go into the build output and end up on the public web. Run any prep step you wouldn't paste into a public Gist with that in mind.\n",
10
- "tr-content-write": "---\nname: tr-content-write\ndescription: Use when the user asks to write, draft, or rewrite a page on a Typeroll site. Loads the site's design conventions before writing so the new content matches the existing voice and style.\n---\n\n# Write a page that fits the site\n\nThe default failure mode for an AI writing a page is \"good generic\nHTML in the wrong voice.\" This skill makes the discovery step\nnon-optional.\n\n## Recipe\n\n### 1. Always discover first\n\n```\nget_site # site name (use it in copy)\nread_site_settings # tagline, contact info, brand colors\nread_partial partial_id=\"header\" # what other pages exist in the nav\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative pages>]\n```\n\nRead the actual HTML of an existing page. Note:\n- Heading structure (single `<h1>` per page? subtitle pattern?)\n- Whether the site uses CSS variables (`var(--color-primary)`) or\n hardcoded values\n- Tone (sober, playful, technical, marketing-y)\n- Length conventions (do existing pages run 200 words or 2000?)\n- Whether internal links use absolute or relative URLs\n\n### 2. Ask for the brief\n\nIf the user hasn't told you, ask:\n\n- **Topic + purpose**: what's the page for, who's it for?\n- **Key points**: must-include facts, calls to action\n- **Target length**: short landing vs. long-form\n- **Audience**: anything specific (existing customers, agencies,\n developers)\n- **Reference page**: is there an existing page to match in tone or\n structure?\n\n### 3. Draft\n\nWrite in semantic HTML, matching the site's conventions you observed\nin step 1:\n\n- Use `<section>`, `<article>`, `<h1>`/`<h2>`, `<p>`, `<ul>` — avoid\n div soup.\n- Match the existing site's class naming or CSS variable usage. Don't\n introduce a new design system mid-page.\n- Insert images via `<img src=\"https://cdn...\" alt=\"...\">` — use\n `list_media` to find existing images first; only generate new ones\n if necessary (see `tr-images` skill).\n- Default status: `draft`. Don't auto-publish unless the user said so.\n\n### 4. Create or update\n\n```\n# New page:\ncreate_page title=\"...\" slug=\"...\" html_content=\"<full body>\"\n status=\"draft\" kind=\"page\"\n seo_title=\"...\" seo_description=\"...\"\n\n# Or update an existing one:\nupdate_page page_id=<id> patch={ html_content: \"...\" }\n```\n\nFor an existing page, `read_page` first and preserve the existing\nstructure — replace one section at a time rather than rewriting the\nwhole body, unless the user explicitly asked for a full redo.\n\n### 5. Preview + iterate\n\n```\nget_preview_link page_id=<id>\n```\n\nShow the URL to the user. Iterate on feedback. Common rounds:\nshortening, adding a CTA, tweaking SEO description.\n\n### 6. Status change is the user's call\n\nDon't `update_page status:\"published\"` without an explicit \"looks\ngood, publish it\" from the user. Same for `trigger_deploy`.\n\n## SEO conventions worth knowing\n\n- **`kind: \"article\"`** for blog posts and news. Switches to\n `og:type=article` + emits Article JSON-LD. Set `author` too — empty\n author = no Person schema = no author rich-result eligibility.\n- **SEO title** target 50-60 chars. Past 60 Google truncates.\n- **Meta description** target 150-160 chars. Don't write fluff to\n fill it; Google rewrites descriptions when they go off-topic.\n- **OG image** per page matters for shareable content. For articles\n especially.\n\n## Pitfalls\n\n- Reading 0 pages and just inventing a design is the most common\n failure. Always sample at least one existing page first.\n- Skipping the brief and producing 1000 words of plausible filler when\n the user wanted a 200-word landing. Ask up front.\n- Auto-publishing. Don't.\n",
10
+ "tr-content-write": "---\nname: tr-content-write\ndescription: Use when the user asks to write, draft, or rewrite a page on a Typeroll site. Loads the site's design conventions before writing so the new content matches the existing voice and style.\n---\n\n# Write a page that fits the site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThe default failure mode for an AI writing a page is \"good generic\nHTML in the wrong voice.\" This skill makes the discovery step\nnon-optional.\n\n## Recipe\n\n### 1. Always discover first\n\n```\nget_site # site name (use it in copy)\nread_site_settings # tagline, contact info, brand colors\nread_partial partial_id=\"header\" # what other pages exist in the nav\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative pages>]\n```\n\nRead the actual HTML of an existing page. Note:\n- Heading structure (single `<h1>` per page? subtitle pattern?)\n- Whether the site uses CSS variables (`var(--color-primary)`) or\n hardcoded values\n- Tone (sober, playful, technical, marketing-y)\n- Length conventions (do existing pages run 200 words or 2000?)\n- Whether internal links use absolute or relative URLs\n\n### 2. Ask for the brief\n\nIf the user hasn't told you, ask:\n\n- **Topic + purpose**: what's the page for, who's it for?\n- **Key points**: must-include facts, calls to action\n- **Target length**: short landing vs. long-form\n- **Audience**: anything specific (existing customers, agencies,\n developers)\n- **Reference page**: is there an existing page to match in tone or\n structure?\n\n### 3. Draft\n\nWrite in semantic HTML, matching the site's conventions you observed\nin step 1:\n\n- Use `<section>`, `<article>`, `<h1>`/`<h2>`, `<p>`, `<ul>` — avoid\n div soup.\n- Match the existing site's class naming or CSS variable usage. Don't\n introduce a new design system mid-page.\n- Insert images via `<img src=\"https://cdn...\" alt=\"...\">` — use\n `list_media` to find existing images first; only generate new ones\n if necessary (see `tr-images` skill).\n- Default status: `draft`. Don't auto-publish unless the user said so.\n\n### 4. Create or update\n\n```\n# New page:\ncreate_page title=\"...\" slug=\"...\" html_content=\"<full body>\"\n status=\"draft\" kind=\"page\"\n seo_title=\"...\" seo_description=\"...\"\n\n# Or update an existing one:\nupdate_page page_id=<id> patch={ html_content: \"...\" }\n```\n\nFor an existing page, `read_page` first and preserve the existing\nstructure — replace one section at a time rather than rewriting the\nwhole body, unless the user explicitly asked for a full redo.\n\n### 5. Preview + iterate\n\n```\nget_preview_link page_id=<id>\n```\n\nShow the URL to the user. Iterate on feedback. Common rounds:\nshortening, adding a CTA, tweaking SEO description.\n\n### 6. Status change is the user's call\n\nDon't `update_page status:\"published\"` without an explicit \"looks\ngood, publish it\" from the user. Same for `trigger_deploy`.\n\n## SEO conventions worth knowing\n\n- **`kind: \"article\"`** for blog posts and news. Switches to\n `og:type=article` + emits Article JSON-LD. Set `author` too — empty\n author = no Person schema = no author rich-result eligibility.\n- **SEO title** target 50-60 chars. Past 60 Google truncates.\n- **Meta description** target 150-160 chars. Don't write fluff to\n fill it; Google rewrites descriptions when they go off-topic.\n- **OG image** per page matters for shareable content. For articles\n especially.\n\n## Pitfalls\n\n- Reading 0 pages and just inventing a design is the most common\n failure. Always sample at least one existing page first.\n- Skipping the brief and producing 1000 words of plausible filler when\n the user wanted a 200-word landing. Ask up front.\n- Auto-publishing. Don't.\n",
11
11
  "tr-design-review": "---\nname: tr-design-review\ndescription: Use to review a deployed/previewed Typeroll page like a designer — a MEASURED multi-dimension pass (responsive, a11y, functional, content, SEO, performance) that emits a per-dimension scorecard and an explicit OK verdict. Run it before telling the user a design is approved; it's the \"how\" for tr-redesign-branch's approval round.\n---\n\n# Review a design — measured, not glanced\n\nA design review is a MEASUREMENT, not a look. The failure mode is reporting\n\"looks good\" off a couple of screenshots — which silently misses overflow at\nuntested widths, sub-AA contrast, broken/blank images, and small touch targets.\nThis skill is the deterministic routine: per dimension, a check you RUN (a\nbrowser-eval snippet or a curl), and a scorecard you fill with PASS / FAIL /\nUNTESTED. Never report \"approved\" off a partial pass — list what you didn't test\nas caveats.\n\n`tr-redesign-branch` step 6 lists the dimensions (the \"what\"). This is the \"how\".\n\n## Cardinal rule: a screenshot is evidence, not proof\n\n**Full-page screenshots lie about lazy-loaded images.** A page with\n`loading=\"lazy\"` images below the fold will screenshot with BLANK boxes where\nthose images sit — they hadn't entered the viewport when the capture fired. If\nyou trust that, you will report a non-existent \"empty illustration box\" gap.\n(This has happened — on a real review, across three variants at once.)\n\nSo, always:\n\n- **Before any full-page capture**, scroll the whole page to trigger lazy loads\n and let it settle (snippet in §5), THEN screenshot.\n- **Verify every suspected blank/broken image via the DOM** (`naturalWidth` after\n scroll), never from the screenshot. A real broken image has `complete === true\n && naturalWidth === 0`; a lazy one that just hasn't loaded has `complete ===\n false` — scroll it into view and re-check before calling it broken.\n\n## Setup\n\n1. Get a URL for the version under review. While iterating, use the DB-live\n `get_preview_link` (mint once, reuse — defaults to a 24h TTL) — it renders\n from the DB with no build, so fixes show on reload without re-deploying, and\n it's the loop for the review-fix-recheck cycle. For a FINAL bit-for-bit check\n of the compiled output before merge, deploy once (`trigger_deploy\n version=\"<branch>\"` → poll `get_deploy_status` → use the immutable\n `deploy_url`, a Cloudflare Pages hash URL). Review the SAME url end to end.\n2. The snippets below run in a browser tool's \"evaluate JavaScript\" (Playwright /\n chrome-devtools / puppeteer MCP). **One origin per eval:** the iframe trick\n needs same-origin, so run each variant's snippet on its own page (different\n `*.pages.dev` hashes are cross-origin → `contentDocument` is null).\n3. If you run several variants with one shared browser profile, do them\n SEQUENTIALLY — parallel browser agents on one profile contaminate each other's\n tabs/screenshots.\n\n## The dimensions — run each, record the result\n\n### 1. Responsive — width ladder 390/768/1024/1440/1920, zero overflow\n\nMeasure horizontal overflow at every width in ONE eval using same-origin iframes\n(each iframe is its own layout viewport, so `@media` fires correctly — no 15\nresizes):\n\n```js\nasync () => {\n const url = location.href, out = [];\n for (const w of [390,768,1024,1440,1920]) {\n const f = document.createElement('iframe');\n f.style.cssText = `width:${w}px;height:2400px;border:0;position:fixed;left:-99999px;top:0`;\n document.body.appendChild(f);\n await new Promise(r => { f.onload = r; f.src = url; });\n await new Promise(r => setTimeout(r, 700));\n const d = f.contentDocument, culprits = [];\n for (const el of d.body.querySelectorAll('*')) {\n const r = el.getBoundingClientRect();\n if (r.right > w + 1 && r.width <= w + 40 && r.width > 4)\n culprits.push(el.tagName.toLowerCase() + '.' + (el.className||'').toString().slice(0,40));\n }\n out.push({ w, hOverflow: d.documentElement.scrollWidth - w, n: culprits.length, sample: [...new Set(culprits)].slice(0,6) });\n f.remove();\n }\n return out;\n}\n```\n\nPASS = `hOverflow <= 0` at every width. A decorative element flagged while\n`hOverflow` is 0 is clipped by an `overflow:hidden` parent (no scrollbar) — a\nnon-issue. Then eyeball one tablet (768) capture for stacking — but capture\nAFTER the scroll-settle in §5.\n\n### 2. Accessibility — compute contrast, don't eyeball\n\n```js\n() => {\n const L = c => { const a = c.map(v => (v/=255, v<=.03928?v/12.92:((v+.055)/1.055)**2.4)); return .2126*a[0]+.7152*a[1]+.0722*a[2]; };\n const P = c => { const m = c.match(/rgba?\\(([^)]+)\\)/); if(!m) return null; const p = m[1].split(',').map(parseFloat); return {rgb:[p[0],p[1],p[2]], a:p[3]??1}; };\n const R = (f,b) => { const x=L(f),y=L(b),h=Math.max(x,y),l=Math.min(x,y); return (h+.05)/(l+.05); };\n const bg = el => { let e=el; while(e){ const s=getComputedStyle(e); if(s.backgroundImage!=='none') return {img:1}; const c=P(s.backgroundColor); if(c&&c.a>.5) return {rgb:c.rgb}; e=e.parentElement; } return {rgb:[255,255,255]}; };\n const bad=[], seen=new Set();\n for (const el of document.body.querySelectorAll('*')) {\n const t=[...el.childNodes].filter(n=>n.nodeType===3&&n.textContent.trim()).map(n=>n.textContent.trim()).join(' ');\n if(!t) continue;\n const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;\n const s=getComputedStyle(el); if(s.visibility==='hidden'||s.display==='none'||+s.opacity<.1) continue;\n const fg=P(s.color); if(!fg) continue;\n const b=bg(el); if(b.img) continue; // can't compute over an image — eyeball hero text separately\n const cr=R(fg.rgb,b.rgb), fs=parseFloat(s.fontSize), fw=+s.fontWeight||400;\n const need = (fs>=24||(fs>=18.66&&fw>=700)) ? 3 : 4.5;\n if (cr<need) { const k=t.slice(0,30)+cr.toFixed(2); if(seen.has(k))continue; seen.add(k);\n bad.push({txt:t.slice(0,45), ratio:+cr.toFixed(2), need, fs:Math.round(fs), fw, color:s.color, bg:'rgb('+b.rgb.join(',')+')'}); }\n }\n return { failures: bad.length, items: bad.slice(0,15) };\n}\n```\n\nPASS = 0 failures (AA: body ≥4.5:1, large/UI ≥3:1). Fix a failure by deepening\nthe offending colour token. Text over an image background is skipped — eyeball\nthose (hero overlays) for legibility separately.\n\nStructure + alt + landmarks, same eval session:\n\n```js\n() => {\n const h=[...document.querySelectorAll('h1,h2,h3,h4')].map(e=>+e.tagName[1]);\n const skips=h.map((v,i)=>i&&v-h[i-1]>1?`${h[i-1]}->${v}`:0).filter(Boolean);\n const imgs=[...document.querySelectorAll('img')];\n const inputs=[...document.querySelectorAll('input:not([type=hidden]),textarea,select')];\n const labelFor=new Set([...document.querySelectorAll('label[for]')].map(l=>l.getAttribute('for')));\n return {\n h1: h.filter(x=>x===1).length, levelSkips: skips,\n imgsMissingAlt: imgs.filter(i=>i.getAttribute('alt')===null).length,\n landmarks: ['header','nav','main','footer'].filter(t=>document.querySelector(t)),\n unlabeledInputs: inputs.filter(i=>!(i.id&&labelFor.has(i.id))&&!i.getAttribute('aria-label')).map(i=>i.name||i.id),\n };\n}\n```\n\nPASS = exactly one `h1`, `levelSkips` empty, `imgsMissingAlt` 0, all four\nlandmarks present, `unlabeledInputs` empty. (Decorative images SHOULD have\n`alt=\"\"` — that's not \"missing\".)\n\nTouch targets — interactive elements ≥44px at mobile. Run in a 390px iframe;\nEXCLUDE `aria-hidden` (the form honeypot is a visible-sized but hidden input —\ncounting it is a false positive) and inline text links inside `p`/`li`:\n\n```js\nasync () => {\n const f=document.createElement('iframe');\n f.style.cssText='width:390px;height:2400px;border:0;position:fixed;left:-99999px;top:0';\n document.body.appendChild(f);\n await new Promise(r=>{ f.onload=r; setTimeout(r,3000); f.src=location.href; });\n await new Promise(r=>setTimeout(r,700));\n const d=f.contentDocument, small=[];\n if(d) for (const el of d.querySelectorAll('a,button,input:not([type=hidden]),textarea,select,[role=button]')) {\n const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;\n const s=getComputedStyle(el); if(s.display==='none'||s.visibility==='hidden'||+s.opacity<.1) continue;\n if(el.getAttribute('aria-hidden')==='true') continue;\n if(el.tagName==='A'&&el.closest('p,li')) continue;\n if(r.height<44||r.width<44) small.push({tag:el.tagName.toLowerCase(), txt:(el.innerText||el.value||el.getAttribute('aria-label')||'').trim().slice(0,24), w:Math.round(r.width), h:Math.round(r.height)});\n }\n f.remove(); return { undersized: small };\n}\n```\n\nAlso confirm `:focus-visible` and `prefers-reduced-motion` exist (grep the page\nHTML: `grep -c 'focus-visible' page.html`, `grep -c 'prefers-reduced-motion'`).\nNote honestly: presence in CSS ≠ verified per-element — tab through live if you\nclaim keyboard focus works.\n\n### 3. Functional — console, links, form\n\n- **Console:** read the browser tool's console messages after load. PASS = 0\n errors/warnings.\n- **Links:** PASS = no `href=\"#\"`/empty; every in-page `#anchor` has a matching\n `id`.\n- **Form (markup — does NOT prove a live submit):** curl the page and verify the\n `<form>` `action` is the real submit endpoint, the hidden `_token` is\n non-empty, the honeypot is present + `aria-hidden`, required fields have\n `required`, the email field is `type=\"email\"`. State explicitly that you did\n NOT submit (a live POST creates a real submission) unless you actually did.\n\n### 4. Content — verbatim, no placeholders\n\n`grep -Ei 'lorem|ipsum|\\{\\{|placeholder|TODO|FIXME' page.html` → 0. Copy matches\nthe live page (the source of truth) verbatim.\n\n### 5. Broken / blank images (the anti-lazy-load check — run THIS before trusting any screenshot)\n\n```js\nasync () => {\n const H=document.body.scrollHeight;\n for(let y=0;y<=H;y+=400){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,120)); }\n window.scrollTo(0,0); await new Promise(r=>setTimeout(r,1500));\n const imgs=[...document.querySelectorAll('img')];\n const empty=[]; // genuinely empty boxes: large, no text/img/svg/bg-image\n for (const el of document.querySelectorAll('div,section,figure')) {\n const r=el.getBoundingClientRect(); if(r.width<160||r.height<140) continue;\n if((el.innerText||'').trim()||el.querySelector('img,svg,picture,canvas,video')) continue;\n if(getComputedStyle(el).backgroundImage!=='none') continue;\n empty.push({cls:(el.className||'').toString().slice(0,36), w:Math.round(r.width), h:Math.round(r.height)});\n }\n return {\n broken: imgs.filter(i=>i.complete&&i.naturalWidth===0).map(i=>i.src.slice(-45)), // real failures\n stillLoading: imgs.filter(i=>!i.complete).map(i=>i.src.slice(-45)), // lazy, scroll first\n emptyBoxes: empty.slice(0,8), // true placeholders\n };\n}\n```\n\nPASS = `broken` empty, `emptyBoxes` empty. A non-empty `emptyBoxes` is a genuine\nunfilled illustration slot (fill it — pages shouldn't be text deserts). NOW\ncapture screenshots (the page is scrolled-and-settled, images loaded).\n\n### 5b. Clipped artwork — the logo (and any brand image) cut off by its own frame\n\nThe single most-repeated visual bug: the header logo rendered with its top/edges\nsliced. It produces ZERO page overflow (§1 misses it), the image isn't broken\n(§5 misses it), and at full-page screenshot scale a few clipped pixels are easy\nto glance past. So MEASURE it: does the artwork's rendered content touch the edge\nof its own box on any side? Content flush against the frame (gap ≈ 0) = clipped\nor about-to-clip. Don't just check the logo — check it, then trust the number.\n\nFor a raster/`<img>` logo, draw it to a same-origin canvas and scan the border\nrows/cols for opaque pixels (cross-origin taints the canvas — fetch the asset to\na localhost file first, as in §setup, or measure on the asset directly):\n\n```js\nasync (url) => { // url = the logo's currentSrc, served same-origin\n const img = new Image(); await new Promise((r,e)=>{img.onload=r;img.onerror=e;img.src=url;});\n const h = 64, w = Math.round(h*img.naturalWidth/img.naturalHeight);\n const c = document.createElement('canvas'); c.width=w; c.height=h;\n const x = c.getContext('2d'); x.drawImage(img,0,0,w,h);\n const d = x.getImageData(0,0,w,h).data, op=(px)=>d[px*4+3]>20;\n let top=h,bot=0,left=w,right=0;\n for(let y=0;y<h;y++)for(let xx=0;xx<w;xx++)if(op(y*w+xx)){top=Math.min(top,y);bot=Math.max(bot,y);left=Math.min(left,xx);right=Math.max(right,xx);}\n return { topGap:top, bottomGap:h-1-bot, leftGap:left, rightGap:right }; // any 0 → flush/clipped\n}\n```\n\nPASS = every gap ≥ ~2% of the dimension. A `0` on any side means the artwork (or\nits stroke) sits on the frame — for an SVG that's a viewBox trimmed flush to the\nart (look for `-trim`/`-tight` in the filename); the fix is to re-export the SVG\nwith viewBox padding (e.g. widen `viewBox` by ~8% each side) so the stroke never\ntouches the edge. Verify the fix by re-running this with the patched asset. (A\nheavy `stroke-width` + `paint-order=\"stroke\"` outline makes a flush viewBox clip\nvisibly — and small header renders make it worse, so also check the logo isn't\nshrunk below ~64–72px in the header.)\n\nAlso confirm no ANCESTOR clips the logo: walk the logo's parents for\n`overflow:hidden|clip` combined with a fixed height or negative/overlap margin —\nand always judge the logo from a screenshot of the header REGION in context,\nnever the logo element in isolation (an element screenshot re-renders the full\nart and hides the clip).\n\n**The inverse bug — an image FLOATING inside its frame (don't blame the file).**\nA full-bleed illustration that renders with a margin of empty frame around it\nusually isn't a bad asset — it's CSS. In blocks-mode the site-template's global\n`:where(.page-content) img{ margin:1rem 0 }` (and a default `border-radius`)\nleaks onto any `<img>` you didn't reset, so a framed hero/figure gets a 1rem gap\ninside its frame and looks like it \"floats\". Before re-cropping or regenerating,\n**open the actual image file** (`curl` the `.avif`/`.png`) — if the motif fills\nthe file edge-to-edge, the float is CSS: set `margin:0` (and `border-radius:0`)\non the framed `<img>` (e.g. `.your-frame img{margin:0}` or a blanket\n`.your-scope img{margin:0}`). Measure it: the `<img>`'s `getBoundingClientRect`\nshould equal its frame's inner box (no gap). Only when the *file itself* has\nbuilt-in background margin (motif ≪ frame) is cropping the right fix.\n\n### 6. Findable (SEO/meta) — curl, fast\n\n`<title>` (≤60 chars) + meta description present + sensible; `og:title/description/image`;\n`canonical`; `favicon` + `apple-touch-icon`; `<html lang>`; branches must be\n`noindex`. One curl + greps covers it.\n\n### 7. Fast (performance)\n\nPASS signals: no render-blocking JS you didn't add; responsive variants\n(`srcset` + AVIF/WebP) so a 1024px asset isn't shipped to a 380px slot;\n`width`/`height` or aspect-ratio set (no layout shift); **below-fold images\n`loading=\"lazy\"`, above-fold `eager`**. Flag a section that eager-loads every\nimage, or a multi-hundred-KB original served when a small AVIF variant exists.\n\n### 8. Cross-browser\n\nThe same CSS renders differently per engine. Re-check in another engine if you\ncan. If only Chromium is available, say so as UNTESTED and statically flag risky\nprops: `backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on\nmobile (prefer `100svh`), `position:sticky` inside `overflow`.\n\n## Deliver a scorecard + an explicit verdict\n\nReport a table — one row per dimension, value PASS / FAIL(detail) / UNTESTED —\nthen a one-line verdict. Rules:\n\n- \"Approved\" requires PASS on responsive, a11y, functional, content, SEO,\n performance. Untested dimensions (commonly cross-browser, live form submit) are\n listed as CAVEATS, not silently dropped — an OK with caveats is honest; an\n unqualified \"approved\" off a partial pass is not.\n- Brand FIT (palette/voice matching `brand.md`) is a direction judgment, not a\n pass/fail defect — call it out separately so the user decides direction.\n- If you fixed anything mid-review, just reload the DB-live preview and re-run\n the affected dimension before signing off — no re-deploy needed (deploy only\n for the final compiled-output check, if any).\n\nSee `tr-redesign-branch` for the surrounding branch → preview → approve → merge\nflow; this skill is its measured approval round.\n",
12
- "tr-directory": "---\nname: tr-directory\ndescription: Use when the user wants to build a directory site or import a structured dataset (restaurants, products, events, agencies, etc.) where each item should have its own URL. Covers collection schema creation, per-item URLs via route_template, listing page, deploy.\n---\n\n# Build a directory site from external data\n\nTyperoll collections support per-item URLs: every published item\nin a collection with a `route_template` materialises as its own static\npage at build time. This is the right pattern when you have hundreds\nof similar entities (restaurants, products, listings, profiles).\n\n## Big-picture flow\n\n1. **Data source** → 2. **Collection schema** → 3. **Items** → 4. **Listing page**\n→ 5. **Preview** → 6. **Deploy**\n\nYou drive everything from Claude Code locally — the scrape, the data\nshaping, the writes. The MCP just receives the final shape.\n\n## Recipe\n\n### 1. Get the data\n\nWhatever source the user has — scraped CSV, public API, vendor feed,\nmanual research, another LLM's output. Normalise to a flat shape:\none object per item with stable, kebab-case field names.\n\n```jsonc\n[\n {\n \"title\": \"Joe's Pizza\",\n \"slug\": \"joes-pizza\",\n \"address\": \"123 Main St, Anytown\",\n \"phone\": \"+1-555-0100\",\n \"cuisine\": \"italian\",\n \"rating\": 4.5,\n \"image\": \"https://...\", // optional: a hosted image URL\n \"excerpt\": \"Family-run since 1987...\",\n \"body\": \"<p>Long-form description with HTML.</p>\"\n }\n]\n```\n\nThe `slug` field is what populates `route_template`. Make it\nkebab-case, unique within the dataset. If the source doesn't have one,\nderive from `title`: lowercase, replace non-alphanumeric with `-`,\ncollapse consecutive dashes.\n\n### 2. Decide the URL structure with the user\n\nCommon patterns:\n\n- `/restaurants/{slug}` (default — simple, predictable)\n- `/r/{slug}` (compact)\n- `/{cuisine}/{slug}` (categorised)\n- `/dir/{slug}` (short prefix to avoid collisions with page slugs)\n\nPick one before creating the collection — changing `route_template`\nlater renames every URL and requires redirect rules.\n\n### 3. Create the collection schema\n\n```\ncreate_collection\n name=\"restaurants\"\n label_singular=\"Restaurant\"\n label_plural=\"Restaurants\"\n icon=\"🍕\"\n fields=[\n {\"name\":\"title\",\"label\":\"Name\",\"type\":\"text\",\"required\":true},\n {\"name\":\"slug\",\"label\":\"Slug\",\"type\":\"text\",\"required\":true},\n {\"name\":\"address\",\"label\":\"Address\",\"type\":\"text\"},\n {\"name\":\"phone\",\"label\":\"Phone\",\"type\":\"text\"},\n {\"name\":\"cuisine\",\"label\":\"Cuisine\",\"type\":\"text\"},\n {\"name\":\"rating\",\"label\":\"Rating\",\"type\":\"number\"},\n {\"name\":\"image\",\"label\":\"Image URL\",\"type\":\"text\"},\n {\"name\":\"excerpt\",\"label\":\"Excerpt\",\"type\":\"textarea\"},\n {\"name\":\"body\",\"label\":\"Body\",\"type\":\"richtext\"}\n ]\n slug_field=\"slug\"\n sort_field=\"title\"\n sort_dir=\"asc\"\n route_template=\"/restaurants/{slug}\"\n item_template_html=\"<article class=\\\"directory-item\\\">\n <header>\n <h1>{{title}}</h1>\n {{cuisine}} · ⭐ {{rating}}\n </header>\n <img src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\" />\n <address>{{address}} · <a href=\\\"tel:{{phone}}\\\">{{phone}}</a></address>\n <section class=\\\"description\\\">{{{body}}}</section>\n </article>\"\n```\n\nThe `item_template_html` is what renders for each item. `{{field}}`\nHTML-escapes; `{{{field}}}` leaves raw (use for richtext bodies that\nintentionally carry HTML).\n\n### 4. Bulk-import items\n\nLoop over your data array. For each item:\n\n```\ncreate_collection_item\n collection=\"restaurants\"\n fields={ title:\"Joe's Pizza\", slug:\"joes-pizza\", ... }\n status=\"published\"\n```\n\nFor larger datasets (1000+), batch outside the MCP — spawn 5 parallel\n`create_collection_item` calls at a time, watch the 60-writes/min rate\nlimit (you'll hit it on big imports, the API returns 429 with\n`Retry-After`).\n\nSet `status: \"draft\"` for items the user still needs to review; only\npublished items get static pages.\n\n### 5. Build a listing page\n\nItems have per-item URLs but not a default index. Create one with a\nmarker pair that `regenerate_collection_listing` will keep up to date:\n\n```\ncreate_page\n title=\"Restaurants\"\n slug=\"restaurants\"\n status=\"published\"\n html_content=\"<h1>All restaurants</h1>\n <!-- typeroll:listing:restaurants -->\n <!-- /typeroll:listing:restaurants -->\n \"\n```\n\nThen populate (and refresh whenever items change) with one call:\n\n```\nregenerate_collection_listing\n collection=\"restaurants\"\n page_id=\"restaurants\"\n item_template=\"<article class=\\\"directory-card\\\">\n <h2><a href=\\\"{{url}}\\\">{{title}}</a></h2>\n <p>{{cuisine}} · ⭐ {{rating}}</p>\n <p>{{address}}</p>\n </article>\"\n wrap_open=\"<div class=\\\"directory-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\n`{{field}}` substitutes HTML-escaped, `{{{field}}}` raw (for richtext\nfields), `{{url}}` resolves through the collection's `route_template`.\nThe tool replaces only what's between the marker pair — anything before\nor after the markers stays put.\n\nWhen the customer adds a new restaurant later, the agent re-runs the\nsame `regenerate_collection_listing` call and the index updates. No\ndiff-the-HTML-by-hand, no stale listings.\n\n(When the block editor lands, you'll be able to drop in a \"collection\nlisting\" block instead of hand-writing this. For now, raw HTML.)\n\n### 6. Preview an item\n\n```\nget_preview_link collection_name=\"restaurants\" item_id=\"<id>\"\n```\n\nReturns a URL the user can open. Internal links inside the preview\nstay inside the preview surface, so navigating to another item works.\n\n### 7. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nEach published item gets its own URL in the static build, with\n`sitemap.xml` automatically including them all.\n\n## Patterns worth knowing\n\n### Conditional rendering without Mustache conditionals\n\n`item_template_html` substitution is plain `{{field}}` / `{{{field}}}` — no loops, no `{{#if}}` blocks. To hide a section/element when a field is empty, use a data-attribute that resolves to either the empty string or a non-empty value, plus a CSS selector:\n\n```html\n<aside class=\"podcast-guest\" data-empty-if-blank=\"{{guest_name}}\">\n <h2>Om gästen</h2>\n <p>{{guest_bio}}</p>\n</aside>\n```\n```css\n.podcast-detail [data-empty-if-blank=\"\"] { display: none !important; }\n```\n\nWhen `guest_name` is empty, the attribute becomes `data-empty-if-blank=\"\"` and the CSS matches and hides the block. When non-empty, the rule misses and the block renders. Works for optional images, optional audio, optional sub-sections — any \"show only if this field has a value\" need.\n\n### Pre-render list-typed source data into a richtext field\n\nFor nested arrays in the source (e.g. `chapters: [{time, title}, …]`), pre-render to HTML during import and store in a dedicated `*_html` richtext field. The template just splats `{{{chapters_html}}}`. Three concrete recipes worth applying:\n\n1. **Podcast detail page:** `chapters_html`, `guest_links_html`.\n2. **Restaurant directory:** `hours_html` table.\n3. **Product directory:** `variants_html` grid.\n\nSee `tr-collection-template` for full code examples.\n\n### Batch-import via subagent for large datasets\n\n22+ `create_collection_item` calls bloat the main agent's context with response payloads. Spawn a `general-purpose` subagent with the manifest path and a tight contract (\"report ok/fail per item, end with a one-line summary\"). The sub returns one line per item instead of a JSON blob per item back into the main turn.\n\n## Pitfalls\n\n- **Slugs must be unique within the collection** — duplicates cause\n build failures (two pages claiming the same URL). De-dupe before\n importing.\n- **Don't reuse `slug` across collections without thinking.**\n `/restaurants/joes` and `/products/joes` are fine; just avoid\n `/joes` for both (collection items vs. pages don't collide because\n pages always win, but two collections sharing a `slug_field=slug`\n with the same `route_template` is a foot-gun).\n- **Required fields.** `route_template=\"/restaurants/{slug}\"` will\n silently skip items where `slug` is missing. Check\n `list_collection_items` after import — if you imported 500 and the\n listing only shows 480, look at the dropped 20's source data.\n- **Template too clever.** Substitution is plain `{{field}}` — no\n loops, no conditionals. If your design needs more, prefer flat\n fields (`star_html`, `rating_label`) prebuilt in the data step.\n See the \"Patterns worth knowing\" section above.\n- **Field type changes drift data.** Adding a new field after import\n is fine; renaming one orphans the old data on every item. Plan the\n schema before import.\n- **Never derive display labels from slugs.** Slugs are ASCII-folded\n for URL-safety. Computing the visible label as\n `slug.replace(\"-\", \" \").capitalize()` produces **wrong words** in\n languages with diacritics: `innehall` → `Innehall` (should be\n `Innehåll`), `affarssystem` → `Affärssystem`. Always carry the\n real title from the source and look it up by slug:\n\n ```python\n slug_to_title = {t[\"slug\"]: t[\"title\"] for t in topic_manifest}\n label = slug_to_title.get(slug, slug.replace(\"-\", \" \").title()) # fallback only\n ```\n\n Caught during a real migration where 20 of 22 podcast detail pages\n ended up with `Innehall` / `Prissattning` / `Affarssystem` in their\n topic chips.\n- **Numeric `sort_field` sorts numerically** as of the 2026-05 fix —\n episode 9 ranks below episode 23 under desc sort. Earlier versions\n compared as strings (9 > 23), so if you're working against an older\n portal deploy add a `sort_key` text field with zero-padded values\n (`f\"{episode:03d}\"`) and set `sort_field: \"sort_key\"` instead.\n\n## Mixing scraped + generated content\n\nThe whole point of the local-agent model: you can blend sources.\n\n- Scrape addresses + phone from a yellow-pages site.\n- Generate excerpt + body from a local Claude pass over the raw\n scraped HTML.\n- Generate hero images per item via `tr-images`.\n- All three merged into one `create_collection_item` per record.\n\nKeep a local manifest (`./directory-state.json`) of what's been\nimported so a partial run is resumable. The MCP doesn't track that\nstate — your local script does.\n",
12
+ "tr-directory": "---\nname: tr-directory\ndescription: Use when the user wants to build a directory site or import a structured dataset (restaurants, products, events, agencies, etc.) where each item should have its own URL. Covers collection schema creation, per-item URLs via route_template, listing page, deploy.\n---\n\n# Build a directory site from external data\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nTyperoll collections support per-item URLs: every published item\nin a collection with a `route_template` materialises as its own static\npage at build time. This is the right pattern when you have hundreds\nof similar entities (restaurants, products, listings, profiles).\n\n## Big-picture flow\n\n1. **Data source** → 2. **Collection schema** → 3. **Items** → 4. **Listing page**\n→ 5. **Preview** → 6. **Deploy**\n\nYou drive everything from Claude Code locally — the scrape, the data\nshaping, the writes. The MCP just receives the final shape.\n\n## Recipe\n\n### 1. Get the data\n\nWhatever source the user has — scraped CSV, public API, vendor feed,\nmanual research, another LLM's output. Normalise to a flat shape:\none object per item with stable, kebab-case field names.\n\n```jsonc\n[\n {\n \"title\": \"Joe's Pizza\",\n \"slug\": \"joes-pizza\",\n \"address\": \"123 Main St, Anytown\",\n \"phone\": \"+1-555-0100\",\n \"cuisine\": \"italian\",\n \"rating\": 4.5,\n \"image\": \"https://...\", // optional: a hosted image URL\n \"excerpt\": \"Family-run since 1987...\",\n \"body\": \"<p>Long-form description with HTML.</p>\"\n }\n]\n```\n\nThe `slug` field is what populates `route_template`. Make it\nkebab-case, unique within the dataset. If the source doesn't have one,\nderive from `title`: lowercase, replace non-alphanumeric with `-`,\ncollapse consecutive dashes.\n\n### 2. Decide the URL structure with the user\n\nCommon patterns:\n\n- `/restaurants/{slug}` (default — simple, predictable)\n- `/r/{slug}` (compact)\n- `/{cuisine}/{slug}` (categorised)\n- `/dir/{slug}` (short prefix to avoid collisions with page slugs)\n\nPick one before creating the collection — changing `route_template`\nlater renames every URL and requires redirect rules.\n\n### 3. Create the collection schema\n\n```\ncreate_collection\n name=\"restaurants\"\n label_singular=\"Restaurant\"\n label_plural=\"Restaurants\"\n icon=\"🍕\"\n fields=[\n {\"name\":\"title\",\"label\":\"Name\",\"type\":\"text\",\"required\":true},\n {\"name\":\"slug\",\"label\":\"Slug\",\"type\":\"text\",\"required\":true},\n {\"name\":\"address\",\"label\":\"Address\",\"type\":\"text\"},\n {\"name\":\"phone\",\"label\":\"Phone\",\"type\":\"text\"},\n {\"name\":\"cuisine\",\"label\":\"Cuisine\",\"type\":\"text\"},\n {\"name\":\"rating\",\"label\":\"Rating\",\"type\":\"number\"},\n {\"name\":\"image\",\"label\":\"Image URL\",\"type\":\"text\"},\n {\"name\":\"excerpt\",\"label\":\"Excerpt\",\"type\":\"textarea\"},\n {\"name\":\"body\",\"label\":\"Body\",\"type\":\"richtext\"}\n ]\n slug_field=\"slug\"\n sort_field=\"title\"\n sort_dir=\"asc\"\n route_template=\"/restaurants/{slug}\"\n item_template_html=\"<article class=\\\"directory-item\\\">\n <header>\n <h1>{{title}}</h1>\n {{cuisine}} · ⭐ {{rating}}\n </header>\n <img src=\\\"{{image}}\\\" alt=\\\"{{title}}\\\" />\n <address>{{address}} · <a href=\\\"tel:{{phone}}\\\">{{phone}}</a></address>\n <section class=\\\"description\\\">{{{body}}}</section>\n </article>\"\n```\n\nThe `item_template_html` is what renders for each item. `{{field}}`\nHTML-escapes; `{{{field}}}` leaves raw (use for richtext bodies that\nintentionally carry HTML).\n\n### 4. Bulk-import items\n\nLoop over your data array. For each item:\n\n```\ncreate_collection_item\n collection=\"restaurants\"\n fields={ title:\"Joe's Pizza\", slug:\"joes-pizza\", ... }\n status=\"published\"\n```\n\nFor larger datasets (1000+), batch outside the MCP — spawn 5 parallel\n`create_collection_item` calls at a time, watch the 60-writes/min rate\nlimit (you'll hit it on big imports, the API returns 429 with\n`Retry-After`).\n\nSet `status: \"draft\"` for items the user still needs to review; only\npublished items get static pages.\n\n### 5. Build a listing page\n\nItems have per-item URLs but not a default index. Create one with a\nmarker pair that `regenerate_collection_listing` will keep up to date:\n\n```\ncreate_page\n title=\"Restaurants\"\n slug=\"restaurants\"\n status=\"published\"\n html_content=\"<h1>All restaurants</h1>\n <!-- typeroll:listing:restaurants -->\n <!-- /typeroll:listing:restaurants -->\n \"\n```\n\nThen populate (and refresh whenever items change) with one call:\n\n```\nregenerate_collection_listing\n collection=\"restaurants\"\n page_id=\"restaurants\"\n item_template=\"<article class=\\\"directory-card\\\">\n <h2><a href=\\\"{{url}}\\\">{{title}}</a></h2>\n <p>{{cuisine}} · ⭐ {{rating}}</p>\n <p>{{address}}</p>\n </article>\"\n wrap_open=\"<div class=\\\"directory-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\n`{{field}}` substitutes HTML-escaped, `{{{field}}}` raw (for richtext\nfields), `{{url}}` resolves through the collection's `route_template`.\nThe tool replaces only what's between the marker pair — anything before\nor after the markers stays put.\n\nWhen the customer adds a new restaurant later, the agent re-runs the\nsame `regenerate_collection_listing` call and the index updates. No\ndiff-the-HTML-by-hand, no stale listings.\n\n(When the block editor lands, you'll be able to drop in a \"collection\nlisting\" block instead of hand-writing this. For now, raw HTML.)\n\n### 6. Preview an item\n\n```\nget_preview_link collection_name=\"restaurants\" item_id=\"<id>\"\n```\n\nReturns a URL the user can open. Internal links inside the preview\nstay inside the preview surface, so navigating to another item works.\n\n### 7. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nEach published item gets its own URL in the static build, with\n`sitemap.xml` automatically including them all.\n\n## Patterns worth knowing\n\n### Conditional rendering without Mustache conditionals\n\n`item_template_html` substitution is plain `{{field}}` / `{{{field}}}` — no loops, no `{{#if}}` blocks. To hide a section/element when a field is empty, use a data-attribute that resolves to either the empty string or a non-empty value, plus a CSS selector:\n\n```html\n<aside class=\"podcast-guest\" data-empty-if-blank=\"{{guest_name}}\">\n <h2>Om gästen</h2>\n <p>{{guest_bio}}</p>\n</aside>\n```\n```css\n.podcast-detail [data-empty-if-blank=\"\"] { display: none !important; }\n```\n\nWhen `guest_name` is empty, the attribute becomes `data-empty-if-blank=\"\"` and the CSS matches and hides the block. When non-empty, the rule misses and the block renders. Works for optional images, optional audio, optional sub-sections — any \"show only if this field has a value\" need.\n\n### Pre-render list-typed source data into a richtext field\n\nFor nested arrays in the source (e.g. `chapters: [{time, title}, …]`), pre-render to HTML during import and store in a dedicated `*_html` richtext field. The template just splats `{{{chapters_html}}}`. Three concrete recipes worth applying:\n\n1. **Podcast detail page:** `chapters_html`, `guest_links_html`.\n2. **Restaurant directory:** `hours_html` table.\n3. **Product directory:** `variants_html` grid.\n\nSee `tr-collection-template` for full code examples.\n\n### Batch-import via subagent for large datasets\n\n22+ `create_collection_item` calls bloat the main agent's context with response payloads. Spawn a `general-purpose` subagent with the manifest path and a tight contract (\"report ok/fail per item, end with a one-line summary\"). The sub returns one line per item instead of a JSON blob per item back into the main turn.\n\n## Pitfalls\n\n- **Slugs must be unique within the collection** — duplicates cause\n build failures (two pages claiming the same URL). De-dupe before\n importing.\n- **Don't reuse `slug` across collections without thinking.**\n `/restaurants/joes` and `/products/joes` are fine; just avoid\n `/joes` for both (collection items vs. pages don't collide because\n pages always win, but two collections sharing a `slug_field=slug`\n with the same `route_template` is a foot-gun).\n- **Required fields.** `route_template=\"/restaurants/{slug}\"` will\n silently skip items where `slug` is missing. Check\n `list_collection_items` after import — if you imported 500 and the\n listing only shows 480, look at the dropped 20's source data.\n- **Template too clever.** Substitution is plain `{{field}}` — no\n loops, no conditionals. If your design needs more, prefer flat\n fields (`star_html`, `rating_label`) prebuilt in the data step.\n See the \"Patterns worth knowing\" section above.\n- **Field type changes drift data.** Adding a new field after import\n is fine; renaming one orphans the old data on every item. Plan the\n schema before import.\n- **Never derive display labels from slugs.** Slugs are ASCII-folded\n for URL-safety. Computing the visible label as\n `slug.replace(\"-\", \" \").capitalize()` produces **wrong words** in\n languages with diacritics: `innehall` → `Innehall` (should be\n `Innehåll`), `affarssystem` → `Affärssystem`. Always carry the\n real title from the source and look it up by slug:\n\n ```python\n slug_to_title = {t[\"slug\"]: t[\"title\"] for t in topic_manifest}\n label = slug_to_title.get(slug, slug.replace(\"-\", \" \").title()) # fallback only\n ```\n\n Caught during a real migration where 20 of 22 podcast detail pages\n ended up with `Innehall` / `Prissattning` / `Affarssystem` in their\n topic chips.\n- **Numeric `sort_field` sorts numerically** as of the 2026-05 fix —\n episode 9 ranks below episode 23 under desc sort. Earlier versions\n compared as strings (9 > 23), so if you're working against an older\n portal deploy add a `sort_key` text field with zero-padded values\n (`f\"{episode:03d}\"`) and set `sort_field: \"sort_key\"` instead.\n\n## Mixing scraped + generated content\n\nThe whole point of the local-agent model: you can blend sources.\n\n- Scrape addresses + phone from a yellow-pages site.\n- Generate excerpt + body from a local Claude pass over the raw\n scraped HTML.\n- Generate hero images per item via `tr-images`.\n- All three merged into one `create_collection_item` per record.\n\nKeep a local manifest (`./directory-state.json`) of what's been\nimported so a partial run is resumable. The MCP doesn't track that\nstate — your local script does.\n",
13
13
  "tr-forms": "---\nname: tr-forms\ndescription: 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.\n---\n\n# Add a form to a Typeroll site\n\nTyperoll forms are server-backed: submissions go to\n`/api/forms/submit` (HMAC-signed, rate-limited, honeypot-protected)\nand are stored in Firestore. The user sees them in the portal's\nSubmissions inbox. No third-party service needed.\n\n## Preconditions\n\n- Site exists and MCP is configured.\n- You know what fields the form needs.\n\n## Forms 2.0 — steps mode (template_capabilities_version ≥ 0.18.0)\n\nPrefer this over the hand-built core/html embed when the portal supports\nit. A form's `steps[]` are Block[] trees of `form/*` field blocks mixed\nwith content blocks; place `{ type: 'core/form', data: { form_id } }` on\nthe page and the build renders everything (token, honeypot, runtime,\nproof-of-work) — you never hand-write the <form> markup.\n\n```\nupdate_form form_id=ansokan patch={ steps: [\n { id: 'steg1', title: 'Om företaget', blocks: [\n { type: 'form/text', data: { name: 'foretag', label: 'Företag', required: true } },\n { type: 'form/email', data: { name: 'epost', label: 'E-post', required: true } } ] },\n { id: 'steg2', title: 'Detaljer', blocks: [\n { type: 'form/textarea', data: { name: 'meddelande', label: 'Meddelande' } },\n { type: 'form/consent', data: { name: 'gdpr', text: '<p>Jag godkänner …</p>' } } ] } ] }\nadd_block target={kind:'page', id:'kontakt'} block={ type: 'core/form', data: { form_id: 'ansokan' } }\n```\n\nValidation derives from the field blocks (required/pattern/min/max) —\nthere is no separate field list to maintain. Partial submissions persist\nper step (TTL `partial_ttl_days`, default 30). Form-scoped CSS goes in\n`styles`; field look is themable via `--form-field-*` tokens in the\nsite's custom_css. On older portals, use the legacy recipe below.\n\n## Recipe\n\n### 1. Create the form definition\n\n```\ncreate_form {\n \"id\": \"kontakt\",\n \"name\": \"Kontaktformulär\",\n \"fields\": [\n {\"name\":\"name\", \"label\":\"Namn\", \"type\":\"text\", \"required\":true},\n {\"name\":\"email\", \"label\":\"E-post\", \"type\":\"email\", \"required\":true},\n {\"name\":\"phone\", \"label\":\"Telefon\", \"type\":\"text\"},\n {\"name\":\"message\", \"label\":\"Meddelande\", \"type\":\"textarea\", \"required\":true},\n {\"name\":\"subject\", \"label\":\"Ämne\", \"type\":\"select\",\n \"options\":[\"Prisförfrågan\",\"Samarbete\",\"Övrigt\"]}\n ],\n \"success_message\": \"Tack! Vi återkommer inom 24 timmar.\"\n}\n```\n\nThere is no `recipient_email` field — submissions land in the portal's\nSubmissions inbox (`/app/sites/{siteId}/forms/{formId}/submissions`).\nEmail notification is a form *action* (not yet wired platform-side);\ntell the customer to check the inbox.\n\n**Field types:** `text`, `email`, `tel`, `url`, `number`, `textarea`,\n`select`, `radio`, `checkbox`.\n\n**Field name rules:** Lowercase ASCII only: `[a-z][a-z0-9_-]*`.\n- `besokt` not `besökt` (`ö→o`)\n- `foretag` not `företag` (`ö→o`, `ä→a`)\n- `meddelande` not `Meddelande` (the `name` must be lowercase; `label` can be anything)\n\n### 2. Get the signed embed token\n\nThe submit endpoint only accepts requests carrying a platform-signed\nHMAC token. `create_form` returns it directly; you can also fetch it any\ntime with:\n\n```\nread_form form_id=\"kontakt\"\n```\n\nThe response includes `submit_token` (put it in the hidden `_token`\ninput) and `submit_url` (use it as the form's `action`). The token is\nstable — it only stops working if the platform rotates its signing\nsecret — so baking it into static page HTML is correct. If\n`submit_token` comes back `null`, the server has no signing secret\nconfigured (dev setups); the form cannot accept submissions until that's\nfixed — tell the user instead of embedding a broken form.\n\n### 3. Embed the form on a page\n\nA plain HTML form POST is the default and needs **no JavaScript**: the\nendpoint answers a normal form-encoded POST with a small confirmation\npage (the form's `success_message` + a link back to the page the\nvisitor came from). Validation errors get the same treatment. The HTML:\n\n```html\n<section class=\"contact-section\">\n <div class=\"contact-container\">\n <h2>Kontakta oss</h2>\n <p>Fyll i formuläret så återkommer vi inom 24 timmar.</p>\n\n <form class=\"contact-form\"\n action=\"SUBMIT_URL\"\n method=\"POST\">\n <!-- The signed token is the only hidden field the platform needs;\n it encodes org + site + form identity. -->\n <input type=\"hidden\" name=\"_token\" value=\"SIGNED_TOKEN\">\n <!-- Honeypot — must stay empty, bots fill it -->\n <input type=\"text\" name=\"_hp\" style=\"display:none\" tabindex=\"-1\" autocomplete=\"off\">\n\n <div class=\"form-group\">\n <label for=\"name\">Namn *</label>\n <input type=\"text\" id=\"name\" name=\"name\" required>\n </div>\n\n <div class=\"form-group\">\n <label for=\"email\">E-post *</label>\n <input type=\"email\" id=\"email\" name=\"email\" required>\n </div>\n\n <div class=\"form-group\">\n <label for=\"message\">Meddelande *</label>\n <textarea id=\"message\" name=\"message\" rows=\"5\" required></textarea>\n </div>\n\n <div class=\"form-group\">\n <label for=\"subject\">Ämne</label>\n <select id=\"subject\" name=\"subject\">\n <option value=\"Prisförfrågan\">Prisförfrågan</option>\n <option value=\"Samarbete\">Samarbete</option>\n <option value=\"Övrigt\">Övrigt</option>\n </select>\n </div>\n\n <button type=\"submit\" class=\"btn-primary\">Skicka meddelande</button>\n </form>\n </div>\n</section>\n\n<style>\n.contact-section{padding:4rem 2rem}\n.contact-container{max-width:600px;margin:0 auto}\n.form-group{margin-bottom:1.5rem}\n.form-group label{display:block;font-weight:600;margin-bottom:0.4rem;font-size:0.9rem}\n.form-group input,.form-group textarea,.form-group select{\n width:100%;padding:0.75rem 1rem;border:1px solid var(--color-surface);\n border-radius:0.375rem;font-family:inherit;font-size:1rem;\n background:var(--color-surface);color:var(--color-text)\n}\n.form-group textarea{resize:vertical}\n.btn-primary{\n background:var(--color-primary);color:#fff;border:none;\n padding:0.875rem 2rem;border-radius:0.375rem;font-size:1rem;\n font-weight:600;cursor:pointer;width:100%\n}\n.btn-primary:hover{opacity:0.9}\n</style>\n```\n\n**Replace:**\n- `SUBMIT_URL` → `submit_url` from `read_form` / `create_form`\n- `SIGNED_TOKEN` → `submit_token` from the same response\n\n### 4. Inline feedback (optional — requires user action)\n\n**The page sanitizer strips inline `<script>` from page and partial\nHTML**, so you cannot ship fetch-based submit handling yourself — a\n`<script>` you put in `html_content` is silently removed. The plain\nPOST above is the reliable path; use it.\n\nIf the customer wants inline feedback without a page reload, the script\nmust go into the site's `scripts_body_end` setting, which only a human\ncan edit (Settings → Custom code — it's deliberately excluded from the\nAI tool surface). Hand them this snippet for that box; it intercepts the\nform and POSTs the JSON shape `{ token, data }`:\n\n```html\n<script>\n(function(){\n const form = document.querySelector('.contact-form');\n if(!form) return;\n form.addEventListener('submit', async (e) => {\n e.preventDefault();\n const btn = form.querySelector('[type=submit]');\n btn.disabled = true;\n btn.textContent = 'Skickar…';\n // The endpoint's fetch contract is JSON: { token, data }.\n const fd = new FormData(form);\n const token = fd.get('_token');\n const data = {};\n fd.forEach((v, k) => { if (k !== '_token') data[k] = v; });\n try {\n const res = await fetch(form.action, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ token, data }),\n });\n const json = await res.json();\n if (res.ok && json.success) {\n form.innerHTML = '<p class=\"form-success\">' + (json.message || 'Tack!') + '</p>';\n } else {\n btn.disabled = false;\n btn.textContent = 'Skicka meddelande';\n alert('Något gick fel: ' + ((json.errors && json.errors.join(', ')) || json.error || 'okänt fel'));\n }\n } catch {\n btn.disabled = false;\n btn.textContent = 'Skicka meddelande';\n alert('Nätverksfel — försök igen.');\n }\n });\n})();\n</script>\n```\n\n### 5. Update the contact page with the form HTML\n\n```\nupdate_page page_id=\"kontakt\" patch={\n \"html_content\": \"<full page HTML including the form section>\"\n}\n```\n\n### 6. Verify\n\n```\nread_form form_id=\"kontakt\"\nlist_forms\n```\n\nConfirm the form appears and fields match what you embedded.\n\n### 7. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nAfter deploy, test by submitting the live form. Submissions appear in the\nportal at `/app/sites/{siteId}/forms/kontakt/submissions`.\n\n## Common form patterns\n\n### Booking / appointment request\n```json\n{\"fields\": [\n {\"name\":\"name\", \"type\":\"text\", \"label\":\"Namn\", \"required\":true},\n {\"name\":\"email\", \"type\":\"email\", \"label\":\"E-post\", \"required\":true},\n {\"name\":\"date\", \"type\":\"text\", \"label\":\"Önskat datum (YYYY-MM-DD)\"},\n {\"name\":\"time\", \"type\":\"select\",\"label\":\"Tid\", \"options\":[\"09:00\",\"10:00\",\"11:00\",\"14:00\",\"15:00\"]},\n {\"name\":\"notes\", \"type\":\"textarea\",\"label\":\"Kommentar\"}\n]}\n```\n\n### Newsletter signup (minimal)\n```json\n{\"fields\": [\n {\"name\":\"email\", \"type\":\"email\", \"label\":\"E-postadress\", \"required\":true}\n]}\n```\n\n### Job application\n```json\n{\"fields\": [\n {\"name\":\"name\", \"type\":\"text\", \"label\":\"Namn\", \"required\":true},\n {\"name\":\"email\", \"type\":\"email\", \"label\":\"E-post\", \"required\":true},\n {\"name\":\"role\", \"type\":\"select\",\"label\":\"Roll\", \"options\":[\"Designer\",\"Projektledare\",\"Övrigt\"]},\n {\"name\":\"experience\", \"type\":\"textarea\",\"label\":\"Berätta om dig själv\"},\n {\"name\":\"portfolio\", \"type\":\"url\", \"label\":\"Portfolio-URL\"}\n]}\n```\n\n## Pitfalls\n\n- **Tokens are stable, not expiring.** The `submit_token` stays valid\n until the platform rotates its signing secret (rare, operator-driven).\n Bake it into the static HTML; no refresh logic needed. If submissions\n suddenly 403 after working, re-fetch via `read_form` and republish.\n- **Inline `<script>` does not survive.** The page sanitizer strips it\n from `html_content` — never rely on client JS you embed yourself. The\n plain form POST works without it (section 3).\n- **Field names must be lowercase ASCII** (`[a-z][a-z0-9_-]*`):\n `foretag` not `företag`, `amne` not `ämne`. Labels can be anything.\n- **Don't use the same form_id on two different forms.** IDs must be\n unique per site — use descriptive names: `kontakt`, `boka`, `nyhetsbrev`.\n- **Honeypot must be invisible.** `_hp` field must have `display:none`.\n If it's visible and a real user fills it, their submission is rejected.\n- **Email notifications are admin-only — not settable via MCP.** Submissions\n are stored and visible in the portal's Forms → Submissions inbox. A site\n admin can also configure post-submission emails (admin notification +\n autoresponder) under **Forms → <form> → Email**, after setting up an email\n connector under **Settings → Email & notifications**. These carry recipient\n addresses + templates over submission data, so they're deliberately off the\n agent surface (`create_form`/`update_form` ignore `actions`). Point the\n customer at those screens; you can't set them up for them.\n",
14
- "tr-header-footer": "---\nname: tr-header-footer\ndescription: Vetted, robust header and footer presets to drop into the header/footer partials. Use when building or restyling a site's site-wide header or footer — start from a preset and restyle it instead of hand-rolling layout + overflow (the usual source of clipped logos and broken mobile menus).\n---\n\n# Header & footer presets\n\nHeaders and footers are the two partials every page shows, and hand-rolling them\nis where logos get clipped and mobile menus break. **Start from a preset below,\nfill the placeholders, restyle with the site's colours — don't build the layout\nfrom scratch.** Each preset is deliberately robust; the \"why\" notes call out the\ntraps it avoids.\n\n## How to use\n\n1. `read_site_settings` — grab `logo`, `site_name`, `tagline`, `contact.email`,\n and the colour palette.\n2. `read_partial partial_id=\"header\"` (and `footer`) — see what's already there;\n don't blow away a working one without reason.\n3. Pick a preset, replace every `{{PLACEHOLDER}}`, adjust colours to the palette\n (the presets already read `--color-*` / `--font-heading` with fallbacks).\n4. `update_partial partial_id=\"header\" patch={ html_content: \"…\" } version=\"…\"`.\n5. **Preview and self-review in context** (see `tr-redesign-branch` step 6):\n the logo must be FULLY VISIBLE (not clipped), legible against its background,\n and the mobile layout must work at 390px. Screenshot the header region in\n context — never the logo element in isolation (that hides clipping).\n\nPlaceholders: `{{SITE_NAME}}`, `{{LOGO_URL}}`, `{{TAGLINE}}`, `{{EMAIL}}`, `{{YEAR}}`.\n\n---\n\n## Header A — Centered logo (minimal; landing pages)\n\n```html\n<header class=\"tr-hdr tr-hdr--center\">\n <a class=\"tr-hdr-logo\" href=\"/\" aria-label=\"{{SITE_NAME}} — till startsidan\">\n <img src=\"{{LOGO_URL}}\" alt=\"{{SITE_NAME}}\" />\n </a>\n</header>\n<style>\n.tr-hdr--center{background:var(--color-surface,#fff);display:flex;justify-content:center;padding:clamp(1rem,2.5vw,1.6rem) 1.5rem}\n.tr-hdr-logo{display:inline-block;line-height:0;transition:transform .15s ease}\n.tr-hdr-logo:hover{transform:translateY(-1px)}\n.tr-hdr-logo img{height:clamp(40px,6vw,58px);width:auto;display:block}\n</style>\n```\n\n**Why it's robust:** no `overflow:hidden` anywhere near the logo (the #1 cause of a\nclipped wordmark); the logo sizes by `height` with `width:auto` so it never\ndistorts and never gets cropped; symmetric padding so it can't collide with the\nsection below. If you want a tinted header, set a solid `background` — don't add a\nglow that has to be clipped.\n\n## Header B — Logo left + links right (no-JS responsive menu)\n\n```html\n<header class=\"tr-hdr tr-hdr--nav\">\n <div class=\"tr-hdr-inner\">\n <a class=\"tr-hdr-logo\" href=\"/\" aria-label=\"{{SITE_NAME}} — till startsidan\">\n <img src=\"{{LOGO_URL}}\" alt=\"{{SITE_NAME}}\" />\n </a>\n <input type=\"checkbox\" id=\"tr-nav-toggle\" class=\"tr-nav-toggle\" aria-hidden=\"true\" />\n <label for=\"tr-nav-toggle\" class=\"tr-nav-burger\" aria-label=\"Meny\"><span></span><span></span><span></span></label>\n <nav class=\"tr-hdr-nav\" aria-label=\"Huvudmeny\">\n <a href=\"/\">Start</a>\n <a href=\"#\">Sidan ett</a>\n <a href=\"#\">Sidan två</a>\n <a class=\"tr-hdr-cta\" href=\"#kontakt\">Kontakta oss</a>\n </nav>\n </div>\n</header>\n<style>\n.tr-hdr--nav{background:var(--color-surface,#fff);border-bottom:1px solid rgba(0,0,0,.06)}\n.tr-hdr-inner{max-width:1160px;margin:0 auto;padding:.9rem 1.5rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap}\n.tr-hdr-logo{line-height:0}\n.tr-hdr-logo img{height:clamp(36px,4.6vw,50px);width:auto;display:block}\n.tr-hdr-nav{display:flex;align-items:center;gap:clamp(1rem,2.4vw,2rem);font-family:var(--font-heading),sans-serif;font-weight:600}\n.tr-hdr-nav a{color:var(--color-text,#1a1a1a);text-decoration:none}\n.tr-hdr-nav a:hover{color:var(--color-primary,#1F4FB8)}\n.tr-hdr-cta{background:var(--color-primary,#1F4FB8);color:var(--color-primary-fg,#fff);padding:.6rem 1.2rem;border-radius:999px}\n.tr-hdr-cta:hover{filter:brightness(1.05);color:var(--color-primary-fg,#fff)}\n.tr-nav-toggle{display:none}\n.tr-nav-burger{display:none;flex-direction:column;gap:5px;cursor:pointer;padding:.4rem}\n.tr-nav-burger span{width:24px;height:2px;background:var(--color-text,#1a1a1a);border-radius:2px}\n@media(max-width:760px){\n .tr-nav-burger{display:flex}\n .tr-hdr-nav{flex-basis:100%;flex-direction:column;align-items:stretch;gap:.2rem;max-height:0;overflow:hidden;transition:max-height .25s ease}\n .tr-hdr-nav a{padding:.7rem .2rem}\n .tr-nav-toggle:checked ~ .tr-hdr-nav{max-height:60vh}\n}\n</style>\n```\n\n**Why it's robust:** the mobile menu is a pure-CSS checkbox toggle — no JS to break,\nno library. The `overflow:hidden` is ONLY on the collapsing nav list (never on the\nheader or the logo), so the logo is always fully visible. Links use site colour\nvariables so it matches the brand automatically. The header wraps (`flex-wrap`) so\nnothing overflows the viewport on narrow screens.\n\n---\n\n## Footer A — Centered minimal\n\n```html\n<footer class=\"tr-ftr tr-ftr--center\">\n <div class=\"tr-ftr-inner\">\n <div class=\"tr-ftr-brand\">{{SITE_NAME}}</div>\n <p class=\"tr-ftr-tag\">{{TAGLINE}}</p>\n <p class=\"tr-ftr-contact\"><a href=\"mailto:{{EMAIL}}\">{{EMAIL}}</a></p>\n <p class=\"tr-ftr-copy\">© {{YEAR}} {{SITE_NAME}}</p>\n </div>\n</footer>\n<style>\n.tr-ftr--center{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}\n.tr-ftr--center .tr-ftr-inner{max-width:1120px;margin:0 auto;padding:2.6rem 1.5rem;text-align:center;display:grid;gap:.45rem}\n.tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff}\n.tr-ftr-tag{margin:0;font-size:1rem;color:rgba(255,255,255,.85)}\n.tr-ftr-contact{margin:.15rem 0 0}\n.tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}\n.tr-ftr-contact a:hover{text-decoration:underline}\n.tr-ftr-copy{margin:.8rem 0 0;font-size:.85rem;color:rgba(255,255,255,.55)}\n</style>\n```\n\n## Footer B — Three columns (brand · links · contact)\n\n```html\n<footer class=\"tr-ftr tr-ftr--cols\">\n <div class=\"tr-ftr-grid\">\n <div class=\"tr-ftr-col\">\n <div class=\"tr-ftr-brand\">{{SITE_NAME}}</div>\n <p class=\"tr-ftr-tag\">{{TAGLINE}}</p>\n </div>\n <nav class=\"tr-ftr-col\" aria-label=\"Sidfot\">\n <a href=\"/\">Start</a>\n <a href=\"#\">Sidan ett</a>\n <a href=\"#\">Sidan två</a>\n </nav>\n <div class=\"tr-ftr-col\">\n <p class=\"tr-ftr-contact\"><a href=\"mailto:{{EMAIL}}\">{{EMAIL}}</a></p>\n </div>\n </div>\n <p class=\"tr-ftr-copy\">© {{YEAR}} {{SITE_NAME}}</p>\n</footer>\n<style>\n.tr-ftr--cols{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}\n.tr-ftr--cols .tr-ftr-grid{max-width:1120px;margin:0 auto;padding:3rem 1.5rem 1.4rem;display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:2rem}\n.tr-ftr--cols .tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff;margin-bottom:.4rem}\n.tr-ftr--cols .tr-ftr-tag{margin:0;color:rgba(255,255,255,.8);max-width:34ch}\n.tr-ftr--cols .tr-ftr-col{display:grid;gap:.5rem;align-content:start}\n.tr-ftr--cols nav a{color:rgba(255,255,255,.85);text-decoration:none}\n.tr-ftr--cols nav a:hover{color:#fff;text-decoration:underline}\n.tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}\n.tr-ftr--cols .tr-ftr-copy{max-width:1120px;margin:0 auto;padding:0 1.5rem 2.4rem;font-size:.85rem;color:rgba(255,255,255,.55)}\n@media(max-width:680px){.tr-ftr--cols .tr-ftr-grid{grid-template-columns:1fr;gap:1.4rem}}\n</style>\n```\n\n**Why these footers are robust:** the columns collapse to one at 680px (no\nhorizontal scroll); all colours come from `--color-*` with fallbacks; the contact\nis a real `mailto:` link; nothing relies on fixed heights. Swap `--color-primary`\nfor a custom dark if the brand's primary is too light for white text.\n\n---\n\n## Restyling notes\n\n- The logo always comes from `read_site_settings → logo`. If it's `null`, set it\n first (upload + `update_site_settings`) — don't hard-code a path.\n- For a **shaped transition** from the header/footer into the page, don't build a\n wave band by hand — that belongs to the adjacent `core/section` via its\n `divider_top` / `divider_bottom` (see `tr-redesign-branch`).\n- Keep the brand mark + a way home. Even a dramatic redesign keeps the logo\n linking to `/`.\n",
14
+ "tr-header-footer": "---\nname: tr-header-footer\ndescription: Vetted, robust header and footer presets to drop into the header/footer partials. Use when building or restyling a site's site-wide header or footer — start from a preset and restyle it instead of hand-rolling layout + overflow (the usual source of clipped logos and broken mobile menus).\n---\n\n# Header & footer presets\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nHeaders and footers are the two partials every page shows, and hand-rolling them\nis where logos get clipped and mobile menus break. **Start from a preset below,\nfill the placeholders, restyle with the site's colours — don't build the layout\nfrom scratch.** Each preset is deliberately robust; the \"why\" notes call out the\ntraps it avoids.\n\n## How to use\n\n1. `read_site_settings` — grab `logo`, `site_name`, `tagline`, `contact.email`,\n and the colour palette.\n2. `read_partial partial_id=\"header\"` (and `footer`) — see what's already there;\n don't blow away a working one without reason.\n3. Pick a preset, replace every `{{PLACEHOLDER}}`, adjust colours to the palette\n (the presets already read `--color-*` / `--font-heading` with fallbacks).\n4. `update_partial partial_id=\"header\" patch={ html_content: \"…\" } version=\"…\"`.\n5. **Preview and self-review in context** (see `tr-redesign-branch` step 6):\n the logo must be FULLY VISIBLE (not clipped), legible against its background,\n and the mobile layout must work at 390px. Screenshot the header region in\n context — never the logo element in isolation (that hides clipping).\n\nPlaceholders: `{{SITE_NAME}}`, `{{LOGO_URL}}`, `{{TAGLINE}}`, `{{EMAIL}}`, `{{YEAR}}`.\n\n---\n\n## Header A — Centered logo (minimal; landing pages)\n\n```html\n<header class=\"tr-hdr tr-hdr--center\">\n <a class=\"tr-hdr-logo\" href=\"/\" aria-label=\"{{SITE_NAME}} — till startsidan\">\n <img src=\"{{LOGO_URL}}\" alt=\"{{SITE_NAME}}\" />\n </a>\n</header>\n<style>\n.tr-hdr--center{background:var(--color-surface,#fff);display:flex;justify-content:center;padding:clamp(1rem,2.5vw,1.6rem) 1.5rem}\n.tr-hdr-logo{display:inline-block;line-height:0;transition:transform .15s ease}\n.tr-hdr-logo:hover{transform:translateY(-1px)}\n.tr-hdr-logo img{height:clamp(40px,6vw,58px);width:auto;display:block}\n</style>\n```\n\n**Why it's robust:** no `overflow:hidden` anywhere near the logo (the #1 cause of a\nclipped wordmark); the logo sizes by `height` with `width:auto` so it never\ndistorts and never gets cropped; symmetric padding so it can't collide with the\nsection below. If you want a tinted header, set a solid `background` — don't add a\nglow that has to be clipped.\n\n## Header B — Logo left + links right (no-JS responsive menu)\n\n```html\n<header class=\"tr-hdr tr-hdr--nav\">\n <div class=\"tr-hdr-inner\">\n <a class=\"tr-hdr-logo\" href=\"/\" aria-label=\"{{SITE_NAME}} — till startsidan\">\n <img src=\"{{LOGO_URL}}\" alt=\"{{SITE_NAME}}\" />\n </a>\n <input type=\"checkbox\" id=\"tr-nav-toggle\" class=\"tr-nav-toggle\" aria-hidden=\"true\" />\n <label for=\"tr-nav-toggle\" class=\"tr-nav-burger\" aria-label=\"Meny\"><span></span><span></span><span></span></label>\n <nav class=\"tr-hdr-nav\" aria-label=\"Huvudmeny\">\n <a href=\"/\">Start</a>\n <a href=\"#\">Sidan ett</a>\n <a href=\"#\">Sidan två</a>\n <a class=\"tr-hdr-cta\" href=\"#kontakt\">Kontakta oss</a>\n </nav>\n </div>\n</header>\n<style>\n.tr-hdr--nav{background:var(--color-surface,#fff);border-bottom:1px solid rgba(0,0,0,.06)}\n.tr-hdr-inner{max-width:1160px;margin:0 auto;padding:.9rem 1.5rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap}\n.tr-hdr-logo{line-height:0}\n.tr-hdr-logo img{height:clamp(36px,4.6vw,50px);width:auto;display:block}\n.tr-hdr-nav{display:flex;align-items:center;gap:clamp(1rem,2.4vw,2rem);font-family:var(--font-heading),sans-serif;font-weight:600}\n.tr-hdr-nav a{color:var(--color-text,#1a1a1a);text-decoration:none}\n.tr-hdr-nav a:hover{color:var(--color-primary,#1F4FB8)}\n.tr-hdr-cta{background:var(--color-primary,#1F4FB8);color:var(--color-primary-fg,#fff);padding:.6rem 1.2rem;border-radius:999px}\n.tr-hdr-cta:hover{filter:brightness(1.05);color:var(--color-primary-fg,#fff)}\n.tr-nav-toggle{display:none}\n.tr-nav-burger{display:none;flex-direction:column;gap:5px;cursor:pointer;padding:.4rem}\n.tr-nav-burger span{width:24px;height:2px;background:var(--color-text,#1a1a1a);border-radius:2px}\n@media(max-width:760px){\n .tr-nav-burger{display:flex}\n .tr-hdr-nav{flex-basis:100%;flex-direction:column;align-items:stretch;gap:.2rem;max-height:0;overflow:hidden;transition:max-height .25s ease}\n .tr-hdr-nav a{padding:.7rem .2rem}\n .tr-nav-toggle:checked ~ .tr-hdr-nav{max-height:60vh}\n}\n</style>\n```\n\n**Why it's robust:** the mobile menu is a pure-CSS checkbox toggle — no JS to break,\nno library. The `overflow:hidden` is ONLY on the collapsing nav list (never on the\nheader or the logo), so the logo is always fully visible. Links use site colour\nvariables so it matches the brand automatically. The header wraps (`flex-wrap`) so\nnothing overflows the viewport on narrow screens.\n\n---\n\n## Footer A — Centered minimal\n\n```html\n<footer class=\"tr-ftr tr-ftr--center\">\n <div class=\"tr-ftr-inner\">\n <div class=\"tr-ftr-brand\">{{SITE_NAME}}</div>\n <p class=\"tr-ftr-tag\">{{TAGLINE}}</p>\n <p class=\"tr-ftr-contact\"><a href=\"mailto:{{EMAIL}}\">{{EMAIL}}</a></p>\n <p class=\"tr-ftr-copy\">© {{YEAR}} {{SITE_NAME}}</p>\n </div>\n</footer>\n<style>\n.tr-ftr--center{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}\n.tr-ftr--center .tr-ftr-inner{max-width:1120px;margin:0 auto;padding:2.6rem 1.5rem;text-align:center;display:grid;gap:.45rem}\n.tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff}\n.tr-ftr-tag{margin:0;font-size:1rem;color:rgba(255,255,255,.85)}\n.tr-ftr-contact{margin:.15rem 0 0}\n.tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}\n.tr-ftr-contact a:hover{text-decoration:underline}\n.tr-ftr-copy{margin:.8rem 0 0;font-size:.85rem;color:rgba(255,255,255,.55)}\n</style>\n```\n\n## Footer B — Three columns (brand · links · contact)\n\n```html\n<footer class=\"tr-ftr tr-ftr--cols\">\n <div class=\"tr-ftr-grid\">\n <div class=\"tr-ftr-col\">\n <div class=\"tr-ftr-brand\">{{SITE_NAME}}</div>\n <p class=\"tr-ftr-tag\">{{TAGLINE}}</p>\n </div>\n <nav class=\"tr-ftr-col\" aria-label=\"Sidfot\">\n <a href=\"/\">Start</a>\n <a href=\"#\">Sidan ett</a>\n <a href=\"#\">Sidan två</a>\n </nav>\n <div class=\"tr-ftr-col\">\n <p class=\"tr-ftr-contact\"><a href=\"mailto:{{EMAIL}}\">{{EMAIL}}</a></p>\n </div>\n </div>\n <p class=\"tr-ftr-copy\">© {{YEAR}} {{SITE_NAME}}</p>\n</footer>\n<style>\n.tr-ftr--cols{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}\n.tr-ftr--cols .tr-ftr-grid{max-width:1120px;margin:0 auto;padding:3rem 1.5rem 1.4rem;display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:2rem}\n.tr-ftr--cols .tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff;margin-bottom:.4rem}\n.tr-ftr--cols .tr-ftr-tag{margin:0;color:rgba(255,255,255,.8);max-width:34ch}\n.tr-ftr--cols .tr-ftr-col{display:grid;gap:.5rem;align-content:start}\n.tr-ftr--cols nav a{color:rgba(255,255,255,.85);text-decoration:none}\n.tr-ftr--cols nav a:hover{color:#fff;text-decoration:underline}\n.tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}\n.tr-ftr--cols .tr-ftr-copy{max-width:1120px;margin:0 auto;padding:0 1.5rem 2.4rem;font-size:.85rem;color:rgba(255,255,255,.55)}\n@media(max-width:680px){.tr-ftr--cols .tr-ftr-grid{grid-template-columns:1fr;gap:1.4rem}}\n</style>\n```\n\n**Why these footers are robust:** the columns collapse to one at 680px (no\nhorizontal scroll); all colours come from `--color-*` with fallbacks; the contact\nis a real `mailto:` link; nothing relies on fixed heights. Swap `--color-primary`\nfor a custom dark if the brand's primary is too light for white text.\n\n---\n\n## Restyling notes\n\n- The logo always comes from `read_site_settings → logo`. If it's `null`, set it\n first (upload + `update_site_settings`) — don't hard-code a path.\n- For a **shaped transition** from the header/footer into the page, don't build a\n wave band by hand — that belongs to the adjacent `core/section` via its\n `divider_top` / `divider_bottom` (see `tr-redesign-branch`).\n- Keep the brand mark + a way home. Even a dramatic redesign keeps the logo\n linking to `/`.\n",
15
15
  "tr-imagegen": "---\nname: tr-imagegen\ndescription: 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.\n---\n\n# Generate images for a Typeroll site (local lab → media library)\n\nImage generation runs **locally** in the site workdir — provider API keys\nlive in the folder's `.env`, candidates land in `images/lab/`, and only\nthe picked winners are uploaded to the Typeroll media library via the\nregular media tools (see `tr-images` for the upload/variants half).\n\nWhy local: you can look at the candidates (Read the files), iterate on\nprompts cheaply, and never ship a key or a reject anywhere.\n\n## Folder convention\n\n```\n<site>/\n├── .env # provider keys — GITIGNORED, never committed\n├── prompts/\n│ └── image-style.md # the site's image style profile (see below)\n└── images/\n └── lab/ # generated candidates — gitignored, disposable\n```\n\n`.env` keys (only the ones the user has — check before assuming):\n\n```\nGEMINI_API_KEY=...\nOPENAI_API_KEY=...\n```\n\n(Higgsfield needs no key here — it connects as an MCP server, see below.)\n\nLoad them per-command (`source .env` doesn't persist between Bash calls):\n\n```bash\nexport $(grep -v '^#' .env | xargs) # prepend to each generation command\n```\n\n## The style profile — prompts/image-style.md\n\nEvery site gets ONE style profile that you **prepend to every image\nprompt**. This is what keeps 20 images generated across 5 sessions\nlooking like one site. Derive it from `assets/brand.md` + the brief if\nit doesn't exist yet, and confirm it with the user before generating at\nscale. Keep it short (5–10 lines): art direction, palette, mood,\nphotography vs illustration, what to avoid.\n\nExample shape:\n\n```markdown\n# Bildstil — <Sajtnamn>\nVarm, folklig illustration med mjuka rundade former. Platt 2D med\nsubtila skuggor — ingen 3D, ingen fotorealism. Palett: kobolt #1F4FB8,\nsol #FFC83D, grädde #FFF8EC; accenter sparsamt. Människor: enkla,\ninkluderande, glada — inga karikatyrer. Undvik: stockfoto-känsla, text\ni bilden, logotyper, watermarks.\n```\n\n## Generate candidates\n\nName candidates descriptively: `images/lab/<motiv>-<modell>-<n>.png`.\nGenerate 3–6 candidates per slot (mix models when several keys exist),\nthen **Read the files to actually look at them** before showing the\nuser your shortlist.\n\n### Gemini (gemini-2.5-flash-image)\n\n```bash\ncurl -s \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent\" \\\n -H \"x-goog-api-key: $GEMINI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"contents\": [{\"parts\": [{\"text\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\"}]}],\n \"generationConfig\": {\"imageConfig\": {\"aspectRatio\": \"16:9\"}}\n }' | jq -r '.candidates[0].content.parts[] | select(.inlineData) | .inlineData.data' \\\n | base64 -d > images/lab/hero-gemini-1.png\n```\n\nAspect ratios: `1:1`, `16:9`, `4:3`, `3:4`, `9:16`. Gemini also does\nimage *editing* — pass an existing image as an `inlineData` part plus an\ninstruction to restyle/extend it (useful for \"same illustration but\nwinter\").\n\n### OpenAI (gpt-image-1)\n\n```bash\ncurl -s https://api.openai.com/v1/images/generations \\\n -H \"Authorization: Bearer $OPENAI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"model\": \"gpt-image-1\",\n \"prompt\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\",\n \"size\": \"1536x1024\",\n \"quality\": \"high\"\n }' | jq -r '.data[0].b64_json' | base64 -d > images/lab/hero-openai-1.png\n```\n\nSizes: `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait).\n\n### Higgsfield (MCP server — no API key)\n\nHiggsfield exposes its models through a hosted MCP server at\n`https://mcp.higgsfield.ai/mcp` (OAuth-protected — first connect opens a\nbrowser login; no secret lands in any file). Add it next to the\ntyperoll server in the site folder's `.mcp.json`:\n\n```json\n\"higgsfield\": { \"type\": \"http\", \"url\": \"https://mcp.higgsfield.ai/mcp\" }\n```\n\nThen use its tools directly — list what's available rather than\nassuming tool names. Save/download outputs into `images/lab/` with the\nsame naming convention, and prepend the style profile to prompts here\ntoo. **Headless caveat:** OAuth-protected MCP servers need an existing\nlogin session on the machine — connect once interactively before\nrelying on it in a headless run. (The same URL works as a custom\nconnector in Claude Desktop, for editors who don't use Claude Code.)\n\n## Review → pick → upload\n\n1. **Look at every candidate** (Read the image files) and write one line\n per candidate in `build-log.md` (keep/reject + why).\n2. Show the user the shortlist (file paths) and let them pick — unless\n they've delegated the pick to you.\n3. Upload winners with the regular media tools (see `tr-images`):\n - small files → `upload_media_inline` (base64);\n - larger → `create_upload_url` + HTTP PUT + `finalize_media`.\n Give real `alt` text and a descriptive filename at upload time.\n4. `generate_image_variants` for responsive sizes when the image is\n placed full-width.\n5. Place via `update_block` (`core/image`, hero fields, …) or page HTML.\n6. `images/lab/` is disposable — leave rejects there; never upload them.\n\n## Pitfalls\n\n- **Never put text in generated images** (headlines, buttons) — text is\n HTML's job; generated text renders as gibberish in non-English. And\n models sneak text onto surfaces where it \"feels natural\" — jerseys,\n signs, banners, packages — even when the prompt doesn't ask for any.\n Forbid it explicitly in the prompt (\"plain unmarked clothing, no\n text, no letters, no numbers anywhere in the image\") and make the\n same rule part of every site's style profile.\n- **Don't commit `.env` or `images/lab/`** — both are gitignored by the\n kit convention; keep it that way.\n- **Cost discipline:** generation costs real money per image. Batch\n thoughtfully (3–6 per slot, not 20), and reuse via Gemini's\n edit-an-image mode instead of regenerating from scratch.\n- **Licensing/provenance:** AI-generated imagery is fine for site\n decoration, but never generate fake \"photos\" of real people, products\n the customer doesn't sell, or anything presented as documentary fact.\n- **The style profile is the contract.** If the user rejects a batch on\n style grounds, fix `prompts/image-style.md` first, then regenerate —\n don't just tweak one prompt.\n",
16
16
  "tr-images": "---\nname: tr-images\ndescription: Use when the user asks for an image, hero, illustration, or logo to be created and embedded in a Typeroll page. Covers the two-step signed-URL upload flow so the agent doesn't try to POST bytes through the CMS API (it can't).\n---\n\n# Add images to a Typeroll site\n\nThe Typeroll API does NOT accept image bytes directly. Uploads go\nthrough a signed PUT URL straight to Cloudflare R2, and the API only\nsees the metadata. Two-step flow:\n\n## Recipe\n\n### 1. Get an image\n\nOptions, in order of preference:\n\na. **Reuse an existing one.** `list_media` returns CDN URLs for every\n image already on this site. Search the list before generating\n anything — saves bandwidth and keeps the visual catalog tight.\n\nb. **Generate locally.** The user's Claude Code installation has\n access to whatever image-gen tools they've configured (DALL-E,\n Midjourney, Stable Diffusion, Replicate, etc.). Generate and save\n to a tempfile.\n\nc. **Source from the web** with appropriate licensing (the user is\n responsible for clearing rights). Save locally before upload.\n\n### 2. Mint a signed upload URL\n\n```\ncreate_upload_url filename=\"hero-services.png\"\n content_type=\"image/png\"\n size=<bytes>\n alt_text=\"Office worker reviewing documents at a desk\"\n```\n\nReturns:\n\n```json\n{\n \"upload_url\": \"https://...r2.cloudflarestorage.com/.../signed-...\",\n \"cdn_url\": \"https://cdn.example.com/orgs/.../images/...png\",\n \"key\": \"orgs/.../images/...png\",\n \"media_id\": \"abc123\",\n \"expires_in\": 300\n}\n```\n\nThe signed URL is valid for 5 minutes. The media doc is already\nregistered — even before the upload completes — so it'll show in\n`list_media` immediately.\n\n### 3. PUT the bytes\n\nOutside the MCP, hit the signed URL directly:\n\n```\nPUT <upload_url>\nContent-Type: <same content_type as in step 2>\nBody: <file bytes>\n```\n\nIn a shell:\n\n```bash\ncurl -sS -X PUT --data-binary @hero-services.png \"$UPLOAD_URL\"\n```\n\nOptionally with `-H \"Content-Type: image/png\"` if you need to override what R2 will infer. **Nothing else.**\n\n> The signed URL embeds checksum-related query parameters\n> (`x-amz-checksum-crc32`, `x-amz-sdk-checksum-algorithm`) for legacy\n> SDK compatibility, but `X-Amz-SignedHeaders=host` — only the host\n> header is part of the signature. Sending the `x-amz-*` values as\n> request headers yields `403 SignatureDoesNotMatch`. Don't. Just\n> `--data-binary` the file at the URL.\n\nOr from JS (Claude Code can run a one-line script):\n\n```js\nawait fetch(uploadUrl, {\n method: 'PUT',\n headers: { 'Content-Type': contentType },\n body: await fs.readFile(path),\n});\n```\n\nParallelise N uploads via shell `&` + `wait`:\n\n```bash\nwhile IFS=$'\\t' read -r filename signed_url; do\n curl -sS -X PUT --data-binary @\"$filename\" \"$signed_url\" &\ndone < manifest.tsv\nwait\n```\n\nA 200 OK from R2 means the image is now live at `cdn_url`.\n\n### 4. Patch metadata (alt text, etc.)\n\nYou set `alt_text` at create time, but if you generate the image first\nand only THEN realize what to caption it as, patch later:\n\n```\nupdate_media media_id=<id> alt_text=\"...\" filename=\"hero-services-v2.png\"\n```\n\n### 4b. Fill missing alt-text on existing media\n\nWhen a customer has uploaded a bunch of images without alt-text (very\ncommon after a WP migration), don't make it up — use vision:\n\n```\nlist_media → find items with empty alt_text\nsuggest_alt_text_context media_id=<id> → returns { image_url, suggested_prompt,\n language, used_on_pages, current_alt_text }\n# Pass image_url + the returned suggested_prompt to YOUR OWN vision\n# capability (you can fetch the URL and pass bytes to vision).\nupdate_media media_id=<id> alt_text=\"<what vision returned>\"\n```\n\nThe prompt is tuned for SEO-grade output: short (5-15 words), no \"image\nof / picture of\" filler, written in the site's content language,\ndecorative images return empty string. Run it sequentially on a\nlist_media batch and you can fix alt-text gaps across a whole site\nwithout burning your context on prompt design. The platform does NOT\nrun vision on your behalf — your model does, your usage.\n\n### 5. Embed in a page\n\n`read_page` the target, insert `<img>` in the right spot:\n\n```html\n<img src=\"<cdn_url>\"\n alt=\"<alt_text>\"\n style=\"width: 100%; height: auto; display: block; margin: 2rem 0;\" />\n```\n\nThen `update_page` with the new HTML. Or, if you're generating a hero\nfor a brand-new page, include the `<img>` directly in `create_page`'s\n`html_content`.\n\n## Pitfalls\n\n- **Always set `alt_text`.** Empty alt is bad for SEO + accessibility.\n Default to a one-sentence description of what's in the image.\n- **`<script>` etc. in SVGs.** The page sanitizer drops `<script>`\n inside SVG, so an icon set that includes script-based animations\n won't render correctly. Use static SVG or a JPG/PNG export.\n- **CSS background-image references aren't dedup'd.** If you set the\n same image as a CSS background on multiple pages, the alt-text +\n metadata are page-irrelevant. The sanitizer allows\n `background-image: url(...)` in inline styles, but think about\n whether an `<img>` is actually better.\n- **Source URL leakage.** If you generated the image from a prompt\n that contains internal info, don't bake that prompt into the\n filename. Use a descriptive but generic filename.\n\n## Format choice\n\n- **PNG** for logos, icons with hard edges, anything with text.\n- **JPG** for photos. Smaller file, better for big hero images.\n- **WebP** if the target audience runs modern browsers (95%+ in 2026).\n- **SVG** for icons + simple illustrations. Vector scales perfectly.\n- **PDF** is supported by `create_upload_url` for document downloads;\n link with `<a href>`, not `<img>`.\n",
17
- "tr-import-url": "---\nname: tr-import-url\ndescription: 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.\n---\n\n# Import content from a non-WordPress site\n\n## When to use this vs tr-migrate-wp\n\n| Source | Use |\n|---|---|\n| WordPress with `/wp-json` accessible | `tr-migrate-wp` |\n| WordPress with REST disabled | This skill (scrape HTML) |\n| Squarespace, Wix, Webflow, static HTML | This skill |\n| CSV / spreadsheet data | This skill (skip scraping, just parse) |\n| Any URL the user points at | This skill |\n\n## Preconditions\n\n- Target Typeroll site exists with working header/footer.\n- Source URL(s) accessible (check with a quick `fetch`; if blocked, mention\n it and ask the user for an HTML export or screenshot).\n\n## Recipe\n\n### 1. Inventory the source site\n\nFetch the homepage and build a URL list:\n\n```\nfetch <source-url> # root HTML\nfetch <source-url>/sitemap.xml # XML sitemap if it exists\n```\n\nParse `<a href>` links to discover internal pages. Build a list:\n- Homepage\n- Top-level pages (About, Services, Contact, etc.)\n- Any sub-pages that look important\n\nAvoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.\n\n### 2. Learn the target's design\n\n```\nread_site_settings\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nThe goal is to understand what CSS variables, class names, and structural\nconventions the target site uses so the imported content looks native.\n\n### 3. Fetch and clean each source page\n\nFor each URL:\n\n**a. Fetch the HTML.**\n```\nfetch <page-url>\n```\n\nIf the site returns a bot-block (Cloudflare, 403, or clearly JS-only\nSPA output), note it. Tell the user: \"This page blocked direct fetching.\nCan you provide the page source or an HTML export?\"\n\n**b. Extract the main content.** \n\nDiscard: nav, header, footer, cookie banners, chat widgets, scripts.\nKeep: `<main>`, `<article>`, the largest content region.\n\nClean the HTML:\n- Strip platform-specific classes: `sqsrte-*`, `wf-*`, `et_*`,\n `elementor-*`, `fl-*`, `divi-*`, `vc_*`\n- Remove empty `<div>`, `<span>`, `<section>` wrappers (no class, no content)\n- Unwrap redundant nesting: `<div><p>text</p></div>` → `<p>text</p>`\n- Keep: `<h1>`–`<h6>`, `<p>`, `<ul>`, `<ol>`, `<img>`, `<a>`, `<table>`,\n `<blockquote>`, `<figure>`, `<figcaption>`, `<strong>`, `<em>`\n- Fix headings: ensure exactly one `<h1>` per page (the page title)\n\n**c. Transfer images.** For each `<img src>`:\n```\nupload_media_from_url url=\"<source-img-url>\" alt=\"...\"\n```\nReplace the src with the returned CDN URL. Skip tracking pixels\n(1×1 images), decorative SVGs that are just icons, and anything\nthat 404s.\n\n**d. Adapt to the target's design.**\nReplace source-specific CSS classes with target conventions.\nUse `var(--color-*)` for colors, `var(--font-*)` for type.\n\n### 4. Create pages as drafts\n\n```\ncreate_page title=\"Om oss\" slug=\"om-oss\"\n html_content=\"<cleaned, adapted HTML>\"\n content_mode=\"html\" status=\"draft\"\n seo_title=\"Om oss — Acme\"\n seo_description=\"...\"\n```\n\nAlways draft first. The user signs off before publishing.\n\n### 5. Handle redirects\n\nIf the source URLs differ from the target slugs, create redirects:\n\n```\ncreate_redirect from_path=\"/about\" to_path=\"/om-oss\"\ncreate_redirect from_path=\"/services.html\" to_path=\"/tjanster\"\n```\n\n### 6. Preview with the user\n\n```\nget_preview_link\n```\n\nWalk through every imported page with the user. Common issues:\n- Heading hierarchy wrong (two H1s, or H3 used where H2 belongs)\n- Images missing alt text\n- Squarespace column layouts that don't work without their grid system\n- Embedded forms or maps that need re-setup\n\n### 7. Publish + deploy\n\nAfter approval:\n```\nbatch_update_pages updates=[\n {page_id: \"om-oss\", patch: {status: \"published\"}},\n {page_id: \"tjanster\", patch: {status: \"published\"}}\n]\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Platform-specific notes\n\n### Squarespace\n- Main content is inside `.content-wrapper` or `[data-section-theme]` blocks\n- Portfolio images are usually high-resolution originals in `/universal/images/`\n- JSON-LD is Squarespace's own schema — strip it\n- Gallery blocks → convert to CSS grid with inline `<img>` tags\n\n### Wix\n- Wix sites are React SPAs — `fetch` returns an empty shell\n- Ask the user for the Wix site's \"Export to HTML\" (available in some plans)\n or take screenshots for reference\n- Best path: get content from the user (text + image files), rebuild clean\n\n### Webflow\n- Usually fetchable; clean output\n- Classes like `w-container`, `w-row`, `w-col-*` can be stripped\n- Webflow CMS items are server-rendered — they appear in the HTML\n\n### Static HTML / old sites\n- Often the cleanest import. Fetch, strip nav/footer, keep body.\n- Watch for table-based layouts (pre-2010 sites) — convert to CSS grid\n\n## Pitfalls\n\n- **Don't import `<style>` blocks from the source site.** They reference\n external fonts, resets, and classes that don't exist in the target.\n Strip all `<style>` tags from source HTML and rewrite styles in the\n target's conventions.\n- **Don't break the single-H1 rule.** Many source sites have no H1 or\n several. Fix it.\n- **Squarespace/Wix forms.** They won't work after import — the backend\n is vendor-locked. Create a Typeroll form instead: `create_form`.\n- **Analytics/tracking code.** If the source has GA4 or similar, don't\n copy it into pages. Set it via `update_site_settings scripts_head=\"...\"`.\n- **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).\n Hosted MP4s need re-uploading if the source URL won't persist.\n",
18
- "tr-migrate-astro": "---\nname: tr-migrate-astro\ndescription: Use when the user wants to migrate an Astro site — particularly an Astro Content Collections-backed site — to Typeroll. Walks `src/content/<collection>/*.md(x)`, lifts frontmatter into Typeroll collection schemas, converts markdown bodies into richtext fields, batch-imports items, then maps `src/pages/*` into Typeroll pages/partials. Triggers on \"migrate an Astro site\", \"import from src/content\", \"convert content collections\", or when the user names a local Astro repo as the source.\n---\n\n# Migrate an Astro site to Typeroll\n\nAstro's [Content Collections](https://docs.astro.build/en/guides/content-collections/) and Typeroll's `Collection` + `CollectionItem` map one-to-one. A collection in Astro is a directory of frontmatter-bearing files under `src/content/<name>/`; in Typeroll it's a schema + items doc set under `organizations/{org}/sites/{site}/collections/{name}/`. The Astro schema (`zod.object(...)` in `src/content/config.ts`) is your field list. The frontmatter values are field values. The markdown bodies are richtext fields.\n\nThis skill walks the migration from a checked-out Astro repo on the user's machine to a target Typeroll site. You run it locally — the source repo is on disk, the MCP just receives the final shape.\n\n## Preconditions\n\n- The Astro repo is checked out locally and `npm install`d (so we can read `src/content/config.ts` to extract the schemas).\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY` pointing at the target site (or org-scoped key + a `site_id` argument per call).\n- Target Typeroll site exists. **Empty starter site is best.** If non-empty, treat existing pages and collections as off-limits unless the user explicitly says otherwise.\n- The Astro design / theme is **not** being migrated — Typeroll has its own design layer. We migrate content; the user re-skins on the Typeroll side.\n\n## Recipe\n\n### 1. Map the source\n\nFrom the Astro repo root:\n\n```bash\nls src/content/ # which collections exist?\ncat src/content/config.ts # collection schemas (zod)\nls src/pages/ # standalone pages\nls public/ # static assets and images\n```\n\nBuild a working manifest:\n\n| Astro source | Typeroll target |\n|---|---|\n| `src/content/blog/*.md` | Collection `blog` with items |\n| `src/content/projects/*.mdx` | Collection `projects` with items |\n| `src/pages/about.astro` | Page with slug `about` |\n| `src/pages/services/[slug].astro` | If dynamic from a collection → that collection's `route_template`. If genuinely per-page → individual Typeroll pages. |\n| `public/og/*.png`, `public/images/*` | Upload to Typeroll media via `upload_media_from_url` (after staging them on a temporary public URL) or via local upload if the MCP supports it. |\n| `src/layouts/*.astro` | Header / footer / shared chunks → Typeroll partials. The rest of the layout is the site design, owned by the target site. |\n| `src/components/*.astro` | Either become partials (if reused across pages) or get inlined into the page that uses them. |\n\n### 2. Learn the target's design (don't skip)\n\n```\nget_site\nread_site_settings # colours, fonts, voice\nlist_partials\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nSame rule as in `tr-migrate-wp`: you're moving content into the *target's* visual language, not preserving the source's. Note the existing fonts, colour vars, header structure.\n\n### 3. Translate one collection schema\n\nPick the most representative collection first (usually `blog`). Read its zod schema:\n\n```ts\n// src/content/config.ts\nconst blog = defineCollection({\n type: 'content',\n schema: z.object({\n title: z.string(),\n description: z.string(),\n pubDate: z.coerce.date(),\n updatedDate: z.coerce.date().optional(),\n heroImage: z.string().optional(),\n author: z.string().default('Editorial'),\n tags: z.array(z.string()).default([]),\n draft: z.boolean().default(false),\n }),\n});\n```\n\nMap zod types to Typeroll field types:\n\n| Astro zod | Typeroll field type |\n|---|---|\n| `z.string()` | `text` (or `textarea` if it's a description/excerpt — judge by typical length) |\n| `z.string().long()` / a description field | `textarea` |\n| `z.coerce.date()` / `z.date()` | `date` |\n| `z.number()` | `number` |\n| `z.boolean()` | `boolean` |\n| `z.string()` with image path / `image()` helper | `image` |\n| `z.array(z.string())` | `text` (comma-joined) or `tags` if you have a tags field type |\n| `z.enum([...])` | `text` with a comment about the allowed values; the model writes the listing logic |\n| `z.object({...})` (nested) | Flatten into prefixed fields, or pre-render into a `*_html` field (see `tr-collection-template`) |\n| Markdown body | `body: richtext` (the markdown content of the file, converted to HTML — see §4) |\n\nCreate the collection with the design template baked in (this is the new pattern — read `tr-blog` if you haven't yet):\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Article\",\n \"label_plural\": \"Articles\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"fields\": [\n {\"name\":\"title\", \"type\":\"text\", \"required\":true},\n {\"name\":\"slug\", \"type\":\"text\", \"required\":true},\n {\"name\":\"description\", \"type\":\"textarea\"},\n {\"name\":\"date\", \"type\":\"date\", \"required\":true},\n {\"name\":\"updated_date\",\"type\":\"date\"},\n {\"name\":\"hero_image\", \"type\":\"image\"},\n {\"name\":\"author\", \"type\":\"text\"},\n {\"name\":\"tags\", \"type\":\"text\"},\n {\"name\":\"body\", \"type\":\"richtext\"}\n ],\n \"item_template_html\": \"<article class=\\\"post\\\">...</article>\"\n}\n```\n\nField-name rules: ASCII, lowercase, `[a-z][a-z0-9_-]*`. The Astro source `pubDate` → Typeroll `date`. `updatedDate` → `updated_date` (snake_case is fine; camelCase isn't).\n\n### 4. Convert markdown bodies → richtext\n\nAstro stores the markdown body as the file content after the `---` frontmatter fence. Typeroll's `body: richtext` wants HTML. Pick one of:\n\n**Option A — use Astro's own markdown renderer (preferred when the repo already builds):**\n\n```js\nimport { unified } from 'unified';\nimport remarkParse from 'remark-parse';\nimport remarkRehype from 'remark-rehype';\nimport rehypeStringify from 'rehype-stringify';\n\nconst md2html = async (md) => {\n const file = await unified()\n .use(remarkParse)\n .use(remarkRehype, { allowDangerousHtml: true })\n .use(rehypeStringify, { allowDangerousHtml: true })\n .process(md);\n return String(file);\n};\n```\n\nRun this on the body of every collection file. Image references inside the markdown (`![alt](./hero.png)`) need their `src` rewritten to the uploaded Typeroll CDN URLs after step 6.\n\n**Option B — convert ad-hoc:** `marked`, `markdown-it`, or any other parser. Just stay consistent across files so the HTML output looks uniform.\n\nTyperoll's sanitiser will strip `<script>` and event handlers from the output regardless. If the markdown carried embedded raw HTML you want to keep (iframes for video, etc.), check the sanitiser config in `packages/site-template/src/lib/sanitize.ts` for the whitelist.\n\n### 5. Resolve and upload images\n\nFor every image referenced by an item (`heroImage`, images inside the markdown body):\n\n1. Stage the local file at a temporary public URL (or use a local-upload MCP tool if one is configured).\n2. `upload_media_from_url url=<staged-url> alt=<from frontmatter or filename>` — record the returned CDN URL.\n3. Substitute the CDN URL into both the `hero_image` field value AND the markdown-converted HTML body (regex replace `src=` references).\n\nKeep a local map (`./astro-migration-state.json`):\n\n```json\n{\n \"media\": {\n \"./hero.png\": \"https://cdn.typeroll.com/<orgId>/<siteId>/abc123.png\"\n }\n}\n```\n\nSo a partial run is resumable and you don't re-upload the same image twice.\n\n### 6. Batch-import items\n\nFor each file in `src/content/<collection>/`:\n\n```js\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport matter from 'gray-matter';\n\nconst files = fs.readdirSync('src/content/blog').filter(f => /\\.mdx?$/.test(f));\nfor (const file of files) {\n const raw = fs.readFileSync(`src/content/blog/${file}`, 'utf8');\n const { data, content } = matter(raw);\n const slug = file.replace(/\\.mdx?$/, '');\n const body = await md2html(content);\n\n // create_collection_item via the MCP\n await mcp.callTool('create_collection_item', {\n collection: 'blog',\n status: data.draft ? 'draft' : 'published',\n fields: {\n title: data.title,\n slug,\n description: data.description,\n date: data.pubDate ? new Date(data.pubDate).toISOString().slice(0, 10) : null,\n updated_date: data.updatedDate ? new Date(data.updatedDate).toISOString().slice(0, 10) : null,\n hero_image: mediaMap[data.heroImage] || data.heroImage,\n author: data.author,\n tags: (data.tags || []).join(', '),\n body,\n },\n });\n}\n```\n\nRate-limit awareness: spawn at most 5 parallel `create_collection_item` calls; the API caps at ~60 writes/minute and responds with 429 + `Retry-After` if exceeded.\n\nSet `status: 'draft'` (or honour Astro's `draft: true` frontmatter) for items the user should review before publishing. Only published items get static pages.\n\n### 7. Build the listing page\n\n```\ncreate_page title=\"Blog\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section><h1>Blog</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n</section>\"\n\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\"><a href=\\\"{{url}}\\\"><h2>{{title}}</h2><p>{{description}}</p><time>{{date}}</time></a></article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\nSee `tr-blog` for full styling and post-import update flow.\n\n### 8. Translate standalone pages\n\nFor each non-dynamic `.astro` page under `src/pages/`:\n\n```\nread_partial partial_id=\"header\" # learn target's nav style\n# Re-skin the page content using target site's CSS variables and partials.\ncreate_page title=\"About\" slug=\"about\" status=\"draft\" content_mode=\"html\"\n html_content=\"...\"\n```\n\nDefault to **draft** — the user reviews each page before publishing.\n\nFor dynamic Astro pages (`[slug].astro` that consume a collection), you're already done: the matching Typeroll collection's `route_template` produces the same URLs at build time.\n\n### 9. Add redirects for URL changes\n\nIf Astro's slugs differed from what you derived for Typeroll (e.g. Astro had `/posts/my-article` but you want `/blog/my-article`), bulk-add redirects:\n\n```\nadd_redirect from=\"/posts/my-article\" to=\"/blog/my-article\" status=301\n```\n\nOr build a redirect map from the `astro-migration-state.json` and apply it in one pass.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nBrowse the resulting site, compare against the Astro source, surface anything that drifted.\n\n## Astro-specific gotchas\n\n- **`.mdx` files with custom components.** Components inside MDX (`<MyCustom prop=\"...\" />`) will not render in Typeroll because the component definitions don't migrate. Two options: (1) replace each component with its rendered HTML output (run the Astro build, scrape the rendered HTML, use that as `body`); (2) if the component is a reusable visual element used across many items, factor it into a Typeroll partial and replace MDX usages with `<x-include name=\"...\" />` calls. See `tr-page-template` for the partial-include pattern.\n- **`src/content/config.ts` typed `image()` helper.** Astro resolves `image()` fields at build time to optimised assets. After migration the Typeroll `hero_image` field carries the original source URL — re-upload via step 5 to get it onto the Typeroll CDN.\n- **`getCollection` filters at runtime.** If `src/pages/blog/index.astro` does `getCollection('blog', ({ data }) => !data.draft)`, Typeroll handles this via `status` on the item. Don't translate the filter — set `status: 'draft'` on items where `data.draft === true`.\n- **Per-tag pages (`/blog/tag/[tag].astro`).** Typeroll doesn't auto-generate these. Either (a) drop tag pages and use a client-side filter in the blog listing JS, (b) generate them by enumerating unique tags and calling `create_page` per tag with a server-side-pre-filtered listing. (a) is preferable for ≤dozens of tags.\n- **`rehype-pretty-code` / shiki / fenced code with syntax highlighting.** The HTML output of these contains inline styles that the Typeroll sanitiser preserves. The colours match the Astro site's theme at import time; redoing the target design later means re-running the highlighter against the same markdown source. Keep a copy of the raw markdown if you might.\n- **`og:image` per page from `astro-og-image` style plugins.** Typeroll has its own SEO surface (`seo_title`, `seo_description`, `seo_og_image`). Set them explicitly on each page during step 8.\n- **i18n via `src/content/<lang>/<collection>/`.** Typeroll's site-level `default_language` + per-page `language` field cover this (capabilities: `supports_language_per_page: true`). Map each language directory to per-item `language: 'sv-SE' | 'en-US' | …` instead of separate collections.\n\n## Mixing imported + new content\n\nHalf-migrate, leave drafts, let the user inspect, iterate. You can:\n\n- Import only collections, skip standalone pages, let the user rebuild those from scratch.\n- Import everything as `status: 'draft'`, treat publishing as a per-item human review pass.\n- Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.\n\nKeep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.\n",
19
- "tr-migrate-wp": "---\nname: tr-migrate-wp\ndescription: Use when the user asks to migrate a WordPress site to Typeroll, mentions wp-json, or names a WP source URL. Walks the WP REST API, rebuilds pages in the target site's design, transfers media, sets redirects, leaves everything as drafts for human review.\n---\n\n# Migrate from WordPress to Typeroll\n\nThe platform's in-portal migration workflow is the \"managed\" path for\ncustomers who want one-click. This skill is the \"power-user\" path: you\ndo it locally, mix data sources freely, and the user (consultant /\nagency) reviews each step in their terminal.\n\n## Preconditions\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The source WP site has `/wp-json` reachable (Google for \"wordpress\n REST API disabled\" if not — common for hardened hosts).\n- The Typeroll target site exists. New, blank sites with the\n starter design work best. If the target already has content, you\n must NOT clobber it — always `list_pages` first and only write to\n slugs that don't already exist.\n\n## Recipe\n\n### 1. Probe and inventory\n\n```\nfetch <wp-url>/wp-json # confirm REST is on\nfetch <wp-url>/wp-sitemap.xml or /sitemap.xml # URL inventory\n```\n\nBuild a list of every URL you intend to migrate. WP custom post types\nneed their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),\nwalking `X-WP-TotalPages` to paginate.\n\n### 2. Learn the target's design\n\n```\nget_site\nread_site_settings # colors, fonts, voice cues\nlist_partials # header / footer / shared\nread_partial partial_id=\"header\" # nav structure\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative ids>] # see actual conventions\n```\n\nDon't skip this. Imposing a stranger's design on a customer's site is\nthe biggest avoidable mistake.\n\n### 3. Migrate one page at a time, draft status\n\nFor each source URL:\n\na. Fetch from WP. Prefer the helper plugin's authenticated endpoint\n (`/wp-json/typeroll/v1/...`) if available — it bypasses\n `show_in_rest=false` and returns ACF + builder fields. Otherwise\n fall back to `/wp-json/wp/v2/<post-type>?slug=<slug>`.\n\nb. Clean the HTML. Strip Elementor / Gutenberg / Breakdance class\n soup. Drop empty `<div>` and `<span>` wrappers. Keep semantic tags,\n tables, iframes from known hosts (YouTube / Vimeo / Calendly).\n\nc. Migrate referenced images:\n - For each `<img src>` and CSS `background-image: url()`:\n 1. Download the source image locally.\n 2. `create_upload_url filename=... content_type=...` → returns\n `{ upload_url, cdn_url, media_id }`.\n 3. PUT the bytes to `upload_url` (curl or fetch with the same\n content type).\n 4. Replace the `src` with `cdn_url` in the rewritten HTML.\n - Use `update_media media_id=... alt_text=\"...\"` to set a real alt\n text (existing WP `alt` attribute or `aria-label`; fall back to\n filename only as a last resort).\n\nd. Reconstruct in the target's design. The cleaned HTML is rarely\n ready to ship — typical fixes: replace WP `wp-block-*` classes\n with the target's CSS variables; turn Elementor sections into\n plain `<section>` with the target's spacing; fix headings so the\n page has exactly one `<h1>`. If you're confident, batch these\n through `bulk_replace_text` with `dry_run: true` first.\n\ne. Write the page as a draft:\n\n ```\n create_page title=\"...\" slug=\"<preserved-from-wp>\"\n html_content=\"<reconstructed>\"\n status=\"draft\" kind=\"article\" author=\"...\"\n seo_title=\"...\" seo_description=\"...\"\n ```\n\n **Preserve the source URL.** WP post URLs like\n `/2024/01/foo-bar/` go in as `slug: \"2024/01/foo-bar\"`. The\n slug supports slashes; encode the WP permalink structure verbatim\n when the customer wants existing links to keep working.\n\n### 4. Redirects\n\nAfter migration, every URL the agent didn't preserve verbatim needs a\nredirect:\n\n```\ncreate_redirect from_path=\"/old-services\" to_path=\"/services\"\n```\n\nWalk the inventory; for each URL: did it become a page with the same\npath? If yes, no redirect. If renamed, `create_redirect`. If\nintentionally dropped, mark it excluded in your notes (the customer\nshould sign off on every dropped URL).\n\n### 5. Preview + review with the user\n\n```\nget_preview_link page_id=<id> # one URL the user can click\n```\n\nOpen in the user's browser. The preview navigates the whole site from\none mint. Iterate on feedback: pages, header, footer.\n\n### 6. Ship\n\nWhen the user signs off:\n\n```\n# Bulk-publish drafts that look right\nbatch_update_pages updates=[{page_id, patch:{status:\"published\"}}, ...]\n\n# Deploy\ntrigger_deploy\nget_deploy_status job_id=<id> # poll\n```\n\n## Pitfalls\n\n- **Don't publish during migration.** Always import as `draft`. Even\n if the agent is confident, the customer needs the chance to spot-check.\n- **WP slugs sometimes drift.** A post saved with slug `foo-bar` may\n have been served at `/2024/01/foo-bar/` due to the permalink\n structure. The full URL is what users see in Google; preserve that,\n not the bare slug.\n- **Image bandwidth.** R2 upload is metered. Use `find_pages_matching`\n contains=\"<old-domain>\" on already-imported content to spot images\n that weren't transferred.\n- **WP-specific JSON-LD** (Yoast, Rank Math) is usually wrong after a\n redesign because it references old URLs. Strip it; let Typeroll\n emit fresh Article/Page schemas via `kind: 'article'` + `author`.\n\n## When the source isn't WordPress\n\nThe same shape applies for any source — Squarespace export, custom\nCMS, scraped HTML, CSV. Replace step 1's \"WP REST\" probe with whatever\ndiscovery the source supports, and the rest of the recipe is unchanged.\n",
20
- "tr-new-site": "---\nname: tr-new-site\ndescription: 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]\".\n---\n\n# Bootstrap a new Typeroll site\n\nStart here when the site already exists as a database record (created via\nthe portal UI or API) but has no design, no header/footer, and no pages.\nThe goal is to go from blank to a working 4-page site with correct brand\nidentity in a single session.\n\n**Pages are built in block mode** — the platform default. Blocks give\nstructured, per-field editing, native full-bleed sections, responsive\nbreakpoints, and templates. HTML mode is the secondary path for\nhand-crafted one-offs and migrated content (section at the end).\n\n## Preconditions\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The site exists (confirm with `get_site`).\n- You have the customer brief: company name, industry, 2–3 key brand colors,\n tone of voice, and a list of initial pages.\n\n## Recipe\n\n### 1. Audit current state\n\n```\nget_site\nget_site_capabilities # template_capabilities_version — what this deployment supports\nread_site_settings # see what (if anything) is already configured\nlist_pages # don't overwrite pages that already exist\nlist_partials # check if header/footer already have content\nlist_block_types # the per-site block palette — NEVER assume, always list\n```\n\n### 2. Brand + settings\n\nOne `update_site_settings` call with every field you know:\n\n```json\n{\n \"site_name\": \"Acme Studio\",\n \"tagline\": \"Short, punchy tagline\",\n \"language\": \"sv\",\n \"colors\": {\n \"primary\": \"#1a1a2e\",\n \"secondary\": \"#16213e\",\n \"accent\": \"#e94560\",\n \"background\": \"#f5f5f5\",\n \"surface\": \"#ffffff\",\n \"text\": \"#1a1a2e\",\n \"text_light\": \"#6b7280\"\n },\n \"fonts\": { \"heading\": \"Playfair Display\", \"body\": \"Inter\", \"size_base\": 16 },\n \"contact\": { \"email\": \"hej@acme.se\", \"phone\": \"+46 8 123 456\" },\n \"social\": { \"instagram\": \"https://instagram.com/acme\" }\n}\n```\n\nRead it back with `read_site_settings`. Google Fonts names are\ncase-sensitive display names (\"Plus Jakarta Sans\", not \"plus jakarta\").\n\n**Site icons are part of brand setup** — upload favicon (32–64px) and a\n180×180 apple touch icon, set `favicon` + `apple_touch_icon`. No icon\nassets? Derive a proposal (see `tr-brand`). Also set `settings.logo` to\nthe uploaded brand mark — it feeds OG/schema even if the header uses a\ndifferent lockup.\n\n### 3. Header + footer partials\n\n**Start from a vetted preset — don't hand-roll the layout.** `read_skill\ntr-header-footer` has robust header + footer presets (centered logo, logo+nav\nwith a no-JS mobile menu, centered + 3-column footers) that avoid the usual\ntraps: clipped logos (no `overflow:hidden` near the logo), distorted logos\n(`height` + `width:auto`), and broken mobile menus. Fill the placeholders and\nrestyle to the palette.\n\nPartials are usually simplest in HTML mode (one nav, a few links — no\nper-field editing needed). Keep them lean; literal site name (no template\nengine in partials):\n\n```html\n<header class=\"site-header\">\n <div class=\"header-inner\">\n <a class=\"header-logo\" href=\"/\"><img src=\"LOGO_MEDIA_URL\" alt=\"Acme Studio\" height=\"40\" /></a>\n <nav class=\"header-nav\">\n <a href=\"/om-oss\">Om oss</a>\n <a href=\"/kontakt\">Kontakt</a>\n </nav>\n </div>\n</header>\n<style>\n.site-header{background:var(--color-background);padding:1rem 2rem}\n.header-inner{max-width:1080px;margin:0 auto;display:flex;align-items:center;justify-content:space-between}\n.header-nav{display:flex;gap:2rem}\n.header-nav a{color:var(--color-text);text-decoration:none}\n</style>\n```\n\n`replace_partial partial_id=\"header\" html_content=\"...\"` — same pattern\nfor the footer. Design notes: **no border-bottom on the header if the\nfirst page section should meet it seamlessly** — let background color\nchanges do the separating. Anchor links in nav (`/#section`) are fine.\n\n### 4. Homepage — block tree\n\n`create_page` with the whole tree in one call. Omit block `id`s — the\nplatform assigns them (`blk_…`); you read them back for later\n`update_block` calls.\n\n```\ncreate_page title=\"Start\" slug=\"\" status=\"draft\" content_mode=\"blocks\" blocks=[...]\n```\n\nA proven landing-page skeleton (every top-level block is a `core/section`\n— sections are **natively full-bleed**: the background runs edge-to-edge,\ncontent is constrained by the section's own inner container via the\n`width` field. NEVER use 100vw negative-margin hacks. Anchor ids and\ncustom classes via `style_overrides` are safe on sections from\ntemplate_capabilities_version ≥ 0.15.3; on 0.14.x–0.15.2 they wrap the\nsection in a div and silently break full-bleed — there, put the anchor\non a block *inside* the section instead):\n\n```json\n[\n { \"type\": \"core/section\", \"data\": { \"background\": \"#ffffff\", \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/media_card\", \"data\": {\n \"image\": \"MEDIA_URL\", \"image_alt\": \"…\", \"image_side\": \"right\",\n \"heading\": \"Huvudrubriken\", \"heading_level\": \"h2\",\n \"text\": \"<p>Ingress …</p>\",\n \"button_label\": \"Kontakta oss\", \"button_url\": \"/#kontakt\"\n } }\n ] },\n { \"type\": \"core/section\", \"data\": { \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/heading\", \"data\": { \"text\": \"Så funkar det\", \"level\": \"h2\", \"align\": \"center\" } },\n { \"type\": \"core/grid\", \"data\": { \"cols\": 3, \"gap\": \"lg\" }, \"children\": [\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"1\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"2\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"3\", \"title\": \"…\", \"text\": \"<p>…</p>\" } }\n ] }\n ] },\n { \"type\": \"core/cta\", \"data\": {\n \"heading\": \"Redo att börja?\",\n \"primary_label\": \"Kontakta oss\", \"primary_url\": \"/kontakt\"\n } }\n]\n```\n\nBlock-palette guidance (verify against `list_block_types` — the source of\ntruth):\n\n- **Hero:** `core/media_card` inside a white section (image beside copy),\n or `core/hero` (eyebrow/heading/subheading + `primary_*`/`secondary_*`\n buttons — rendered server-side; `layout: split-right` puts the image\n beside the text). For a plain text hero: section + heading + prose +\n button.\n- **`core/heading`** decouples `level` (h1–h6, semantics) from `size`\n (visual) — exactly one `level: h1` per page.\n- **Images:** `core/image` with an uploaded media URL. The build pipeline\n automatically emits responsive `<picture>` with AVIF/WebP variants\n (run `generate_image_variants` after upload) — the in-portal preview\n shows a plain `<img>`, the deployed site gets the upgrade. Use the\n `radius` field for rounded corners.\n- **Repeaters/listings:** `core/collection_list`, `gallery`,\n `feature_grid` etc. — alias blocks over `core/repeater`. Use these for\n collection-driven content instead of hand-writing listing markup.\n- **Forms:** `create_form`, then a `core/html` block carrying the plain\n `<form method=\"POST\" action={submit_url}>` embed with the hidden\n `_token` — see `tr-forms`.\n- **`core/html`** is the escape hatch for the genuinely unique thing —\n not a default. If you reach for it more than once or twice per page,\n note why (that's block-library feedback).\n\nSlot containers (`core/columns`, `core/tabs`): populate them either by\npassing the whole tree inline (`block={ type: 'core/columns', slots:\n[[…],[…]] }`) or incrementally with `add_block parent_id=<columns-id>\nslot_index=0|1`. Requires template_capabilities_version ≥ 0.15.2 — on\nolder sites use `core/grid` (children flow into columns) instead.\n\nPartial last rows (template_capabilities_version ≥ 0.16.5): when N equal\ncards don't divide by the grid's column count (5 cards, 3 cols), set\n`last_row: 'center'` on the `core/grid` — the orphan row auto-centers.\nNEVER invent a \"wide\" variant of one peer card to fill the hole, and\ndon't hand-roll 6-column CSS tricks; both distort content to patch\nlayout. On older sites, pick a column count that divides N.\n\nIcons (template_capabilities_version ≥ 0.16.0): `type: 'icon'` fields on\n`core/icon`, `core/icon_box`, and `core/step_card` render inline SVG when\nthe value is a name from `get_site_capabilities → core_icon_names` (a\ncurated Lucide subset — `check`, `star`, `shield-check`, `mail`,\n`arrow-right`, `truck`, `chart-line`, …). Any other value (emoji, plain\ntext) renders as text, so emoji stand-ins keep working. Icons inherit\nsize from font-size and color from `currentColor`/the block's color\nfield. On older sites icons don't render — use emoji or CSS markers.\n\nKnown limitations (honest list — don't fight them):\n\n- **`core/tabs` label icons don't render** (the tab strip is built\n client-side without the icon pipeline). Text labels only.\n\nTheming: block primitives render neutral. Brand color/typography comes\nfrom settings (step 2). For page-specific polish (e.g. a colored card\ntreatment), a single `core/html` block with a small `<style>` scoped to\n`[data-bid]`/section selectors is acceptable — keep it minimal and note\nit in your log.\n\n### 5. Inner pages\n\nSame pattern: `create_page` with `content_mode: \"blocks\"` and a section\ntree. Standard set: Om oss, Tjänster, Kontakt — or what the brief says.\nDefault new pages to `status: \"draft\"`; publish after review.\n\n### 5b. If the legacy site is still live, scrape canonical content\n\nFor pages with canonical text (privacy policy, terms, about), `WebFetch`\nthe live page and carry the text verbatim — don't rewrite legal copy\nfrom memory. Convert to prose blocks (or one `core/html` for complex\nlegacy markup).\n\n### 6. Preview + iterate\n\n```\nget_preview_link # signed URL for browser review\nget_page_preview page_id=\"home\" # rendered HTML for structural checks\n```\n\n**Self-review the visuals before you call it done — appearance AND\nreadability, not just structure.** Screenshot the deployed/preview site at\ndesktop (~1440px) and mobile (~390px) and look: logo FULLY VISIBLE (not clipped by\na header's overflow:hidden) + legible + brand-compliant against its actual\nbackground — screenshot the header IN CONTEXT, not the logo element in isolation\n(an element shot hides layout clipping); a light wordmark must not sit bare on a\nlight surface. Text contrast everywhere, no horizontal scroll or mid-word\nbreaks, every image rendered, mobile layout actually collapsed. \"No overflow +\ncopy present\" is not a design review — never report a build as done/perfect off\nstructural metrics alone.\n\nShare the preview link. Iterate on feedback with `update_block` /\n`add_block` / `move_block` — that's the point of block mode: surgical\nedits, not full-page rewrites.\n\n### 7. Deploy\n\nWhen the user approves:\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Secondary path: HTML mode\n\nFor migrated legacy pages or a hand-crafted one-off, `content_mode:\n\"html\"` still works. Rules that apply there (and only there):\n\n- Wrap the body in a page-scope `<article class=\"my-page\">` and prefix\n selectors with it — the `.page-content` shell sets width/padding at\n normal specificity.\n- HTML-mode bodies are container-constrained; full-bleed requires the\n negative-margin escape (`margin-left: calc(50% - 50vw); width: 100vw`)\n — never combine with `overflow-x: clip` on a wrapper.\n- `set_page_mode` flips a page between modes;\n `convert_page_to_blocks` does a heuristic HTML→blocks conversion.\n\n## Pitfalls\n\n- **Don't create pages that already exist** — `list_pages` first; use\n `update_page` if the slug is taken.\n- **`update_page` takes a `patch` object**, not flat fields.\n- **Don't hardcode block field names from memory** — schemas are\n per-site (`list_block_types`/`read_block_type`).\n- **One `level: h1` per page** (core/heading) — SEO + screen readers.\n- **Don't simplify data during import** — preserve Swedish characters in\n labels (`affärsutveckling`, not the ASCII-folded slug), keep titles\n verbatim; slugs are derived for URLs only.\n- **Minimal JS.** Inline `<script>` in page content is stripped by the\n sanitizer. Interactivity ships via block-type `script` (requires the\n site's AI-scripts opt-in) or the human-managed `scripts_body_end`.\n",
17
+ "tr-import-url": "---\nname: tr-import-url\ndescription: 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.\n---\n\n# Import content from a non-WordPress site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\n## When to use this vs tr-migrate-wp\n\n| Source | Use |\n|---|---|\n| WordPress with `/wp-json` accessible | `tr-migrate-wp` |\n| WordPress with REST disabled | This skill (scrape HTML) |\n| Squarespace, Wix, Webflow, static HTML | This skill |\n| CSV / spreadsheet data | This skill (skip scraping, just parse) |\n| Any URL the user points at | This skill |\n\n## Preconditions\n\n- Target Typeroll site exists with working header/footer.\n- Source URL(s) accessible (check with a quick `fetch`; if blocked, mention\n it and ask the user for an HTML export or screenshot).\n\n## Recipe\n\n### 1. Inventory the source site\n\nFetch the homepage and build a URL list:\n\n```\nfetch <source-url> # root HTML\nfetch <source-url>/sitemap.xml # XML sitemap if it exists\n```\n\nParse `<a href>` links to discover internal pages. Build a list:\n- Homepage\n- Top-level pages (About, Services, Contact, etc.)\n- Any sub-pages that look important\n\nAvoid: pagination URLs, session URLs, `/wp-admin`, `/cdn-cgi/`, query strings.\n\n### 2. Learn the target's design\n\n```\nread_site_settings\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nThe goal is to understand what CSS variables, class names, and structural\nconventions the target site uses so the imported content looks native.\n\n### 3. Fetch and clean each source page\n\nFor each URL:\n\n**a. Fetch the HTML.**\n```\nfetch <page-url>\n```\n\nIf the site returns a bot-block (Cloudflare, 403, or clearly JS-only\nSPA output), note it. Tell the user: \"This page blocked direct fetching.\nCan you provide the page source or an HTML export?\"\n\n**b. Extract the main content.** \n\nDiscard: nav, header, footer, cookie banners, chat widgets, scripts.\nKeep: `<main>`, `<article>`, the largest content region.\n\nClean the HTML:\n- Strip platform-specific classes: `sqsrte-*`, `wf-*`, `et_*`,\n `elementor-*`, `fl-*`, `divi-*`, `vc_*`\n- Remove empty `<div>`, `<span>`, `<section>` wrappers (no class, no content)\n- Unwrap redundant nesting: `<div><p>text</p></div>` → `<p>text</p>`\n- Keep: `<h1>`–`<h6>`, `<p>`, `<ul>`, `<ol>`, `<img>`, `<a>`, `<table>`,\n `<blockquote>`, `<figure>`, `<figcaption>`, `<strong>`, `<em>`\n- Fix headings: ensure exactly one `<h1>` per page (the page title)\n\n**c. Transfer images.** For each `<img src>`:\n```\nupload_media_from_url url=\"<source-img-url>\" alt=\"...\"\n```\nReplace the src with the returned CDN URL. Skip tracking pixels\n(1×1 images), decorative SVGs that are just icons, and anything\nthat 404s.\n\n**d. Adapt to the target's design.**\nReplace source-specific CSS classes with target conventions.\nUse `var(--color-*)` for colors, `var(--font-*)` for type.\n\n### 4. Create pages as drafts\n\n```\ncreate_page title=\"Om oss\" slug=\"om-oss\"\n html_content=\"<cleaned, adapted HTML>\"\n content_mode=\"html\" status=\"draft\"\n seo_title=\"Om oss — Acme\"\n seo_description=\"...\"\n```\n\nAlways draft first. The user signs off before publishing.\n\n### 5. Handle redirects\n\nIf the source URLs differ from the target slugs, create redirects:\n\n```\ncreate_redirect from_path=\"/about\" to_path=\"/om-oss\"\ncreate_redirect from_path=\"/services.html\" to_path=\"/tjanster\"\n```\n\n### 6. Preview with the user\n\n```\nget_preview_link\n```\n\nWalk through every imported page with the user. Common issues:\n- Heading hierarchy wrong (two H1s, or H3 used where H2 belongs)\n- Images missing alt text\n- Squarespace column layouts that don't work without their grid system\n- Embedded forms or maps that need re-setup\n\n### 7. Publish + deploy\n\nAfter approval:\n```\nbatch_update_pages updates=[\n {page_id: \"om-oss\", patch: {status: \"published\"}},\n {page_id: \"tjanster\", patch: {status: \"published\"}}\n]\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Platform-specific notes\n\n### Squarespace\n- Main content is inside `.content-wrapper` or `[data-section-theme]` blocks\n- Portfolio images are usually high-resolution originals in `/universal/images/`\n- JSON-LD is Squarespace's own schema — strip it\n- Gallery blocks → convert to CSS grid with inline `<img>` tags\n\n### Wix\n- Wix sites are React SPAs — `fetch` returns an empty shell\n- Ask the user for the Wix site's \"Export to HTML\" (available in some plans)\n or take screenshots for reference\n- Best path: get content from the user (text + image files), rebuild clean\n\n### Webflow\n- Usually fetchable; clean output\n- Classes like `w-container`, `w-row`, `w-col-*` can be stripped\n- Webflow CMS items are server-rendered — they appear in the HTML\n\n### Static HTML / old sites\n- Often the cleanest import. Fetch, strip nav/footer, keep body.\n- Watch for table-based layouts (pre-2010 sites) — convert to CSS grid\n\n## Pitfalls\n\n- **Don't import `<style>` blocks from the source site.** They reference\n external fonts, resets, and classes that don't exist in the target.\n Strip all `<style>` tags from source HTML and rewrite styles in the\n target's conventions.\n- **Don't break the single-H1 rule.** Many source sites have no H1 or\n several. Fix it.\n- **Squarespace/Wix forms.** They won't work after import — the backend\n is vendor-locked. Create a Typeroll form instead: `create_form`.\n- **Analytics/tracking code.** If the source has GA4 or similar, don't\n copy it into pages. Set it via `update_site_settings scripts_head=\"...\"`.\n- **Videos.** YouTube/Vimeo embeds are fine (`<iframe>` is allowed).\n Hosted MP4s need re-uploading if the source URL won't persist.\n",
18
+ "tr-migrate-astro": "---\nname: tr-migrate-astro\ndescription: Use when the user wants to migrate an Astro site — particularly an Astro Content Collections-backed site — to Typeroll. Walks `src/content/<collection>/*.md(x)`, lifts frontmatter into Typeroll collection schemas, converts markdown bodies into richtext fields, batch-imports items, then maps `src/pages/*` into Typeroll pages/partials. Triggers on \"migrate an Astro site\", \"import from src/content\", \"convert content collections\", or when the user names a local Astro repo as the source.\n---\n\n# Migrate an Astro site to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nAstro's [Content Collections](https://docs.astro.build/en/guides/content-collections/) and Typeroll's `Collection` + `CollectionItem` map one-to-one. A collection in Astro is a directory of frontmatter-bearing files under `src/content/<name>/`; in Typeroll it's a schema + items doc set under `organizations/{org}/sites/{site}/collections/{name}/`. The Astro schema (`zod.object(...)` in `src/content/config.ts`) is your field list. The frontmatter values are field values. The markdown bodies are richtext fields.\n\nThis skill walks the migration from a checked-out Astro repo on the user's machine to a target Typeroll site. You run it locally — the source repo is on disk, the MCP just receives the final shape.\n\n## Preconditions\n\n- The Astro repo is checked out locally and `npm install`d (so we can read `src/content/config.ts` to extract the schemas).\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY` pointing at the target site (or org-scoped key + a `site_id` argument per call).\n- Target Typeroll site exists. **Empty starter site is best.** If non-empty, treat existing pages and collections as off-limits unless the user explicitly says otherwise.\n- The Astro design / theme is **not** being migrated — Typeroll has its own design layer. We migrate content; the user re-skins on the Typeroll side.\n\n## Recipe\n\n### 1. Map the source\n\nFrom the Astro repo root:\n\n```bash\nls src/content/ # which collections exist?\ncat src/content/config.ts # collection schemas (zod)\nls src/pages/ # standalone pages\nls public/ # static assets and images\n```\n\nBuild a working manifest:\n\n| Astro source | Typeroll target |\n|---|---|\n| `src/content/blog/*.md` | Collection `blog` with items |\n| `src/content/projects/*.mdx` | Collection `projects` with items |\n| `src/pages/about.astro` | Page with slug `about` |\n| `src/pages/services/[slug].astro` | If dynamic from a collection → that collection's `route_template`. If genuinely per-page → individual Typeroll pages. |\n| `public/og/*.png`, `public/images/*` | Upload to Typeroll media via `upload_media_from_url` (after staging them on a temporary public URL) or via local upload if the MCP supports it. |\n| `src/layouts/*.astro` | Header / footer / shared chunks → Typeroll partials. The rest of the layout is the site design, owned by the target site. |\n| `src/components/*.astro` | Either become partials (if reused across pages) or get inlined into the page that uses them. |\n\n### 2. Learn the target's design (don't skip)\n\n```\nget_site\nread_site_settings # colours, fonts, voice\nlist_partials\nread_partial partial_id=\"header\"\nlist_pages limit=5\n```\n\nSame rule as in `tr-migrate-wp`: you're moving content into the *target's* visual language, not preserving the source's. Note the existing fonts, colour vars, header structure.\n\n### 3. Translate one collection schema\n\nPick the most representative collection first (usually `blog`). Read its zod schema:\n\n```ts\n// src/content/config.ts\nconst blog = defineCollection({\n type: 'content',\n schema: z.object({\n title: z.string(),\n description: z.string(),\n pubDate: z.coerce.date(),\n updatedDate: z.coerce.date().optional(),\n heroImage: z.string().optional(),\n author: z.string().default('Editorial'),\n tags: z.array(z.string()).default([]),\n draft: z.boolean().default(false),\n }),\n});\n```\n\nMap zod types to Typeroll field types:\n\n| Astro zod | Typeroll field type |\n|---|---|\n| `z.string()` | `text` (or `textarea` if it's a description/excerpt — judge by typical length) |\n| `z.string().long()` / a description field | `textarea` |\n| `z.coerce.date()` / `z.date()` | `date` |\n| `z.number()` | `number` |\n| `z.boolean()` | `boolean` |\n| `z.string()` with image path / `image()` helper | `image` |\n| `z.array(z.string())` | `text` (comma-joined) or `tags` if you have a tags field type |\n| `z.enum([...])` | `text` with a comment about the allowed values; the model writes the listing logic |\n| `z.object({...})` (nested) | Flatten into prefixed fields, or pre-render into a `*_html` field (see `tr-collection-template`) |\n| Markdown body | `body: richtext` (the markdown content of the file, converted to HTML — see §4) |\n\nCreate the collection with the design template baked in (this is the new pattern — read `tr-blog` if you haven't yet):\n\n```\ncreate_collection {\n \"name\": \"blog\",\n \"label_singular\": \"Article\",\n \"label_plural\": \"Articles\",\n \"slug_field\": \"slug\",\n \"sort_field\": \"date\",\n \"sort_dir\": \"desc\",\n \"route_template\": \"/blog/{slug}\",\n \"fields\": [\n {\"name\":\"title\", \"type\":\"text\", \"required\":true},\n {\"name\":\"slug\", \"type\":\"text\", \"required\":true},\n {\"name\":\"description\", \"type\":\"textarea\"},\n {\"name\":\"date\", \"type\":\"date\", \"required\":true},\n {\"name\":\"updated_date\",\"type\":\"date\"},\n {\"name\":\"hero_image\", \"type\":\"image\"},\n {\"name\":\"author\", \"type\":\"text\"},\n {\"name\":\"tags\", \"type\":\"text\"},\n {\"name\":\"body\", \"type\":\"richtext\"}\n ],\n \"item_template_html\": \"<article class=\\\"post\\\">...</article>\"\n}\n```\n\nField-name rules: ASCII, lowercase, `[a-z][a-z0-9_-]*`. The Astro source `pubDate` → Typeroll `date`. `updatedDate` → `updated_date` (snake_case is fine; camelCase isn't).\n\n### 4. Convert markdown bodies → richtext\n\nAstro stores the markdown body as the file content after the `---` frontmatter fence. Typeroll's `body: richtext` wants HTML. Pick one of:\n\n**Option A — use Astro's own markdown renderer (preferred when the repo already builds):**\n\n```js\nimport { unified } from 'unified';\nimport remarkParse from 'remark-parse';\nimport remarkRehype from 'remark-rehype';\nimport rehypeStringify from 'rehype-stringify';\n\nconst md2html = async (md) => {\n const file = await unified()\n .use(remarkParse)\n .use(remarkRehype, { allowDangerousHtml: true })\n .use(rehypeStringify, { allowDangerousHtml: true })\n .process(md);\n return String(file);\n};\n```\n\nRun this on the body of every collection file. Image references inside the markdown (`![alt](./hero.png)`) need their `src` rewritten to the uploaded Typeroll CDN URLs after step 6.\n\n**Option B — convert ad-hoc:** `marked`, `markdown-it`, or any other parser. Just stay consistent across files so the HTML output looks uniform.\n\nTyperoll's sanitiser will strip `<script>` and event handlers from the output regardless. If the markdown carried embedded raw HTML you want to keep (iframes for video, etc.), check the sanitiser config in `packages/site-template/src/lib/sanitize.ts` for the whitelist.\n\n### 5. Resolve and upload images\n\nFor every image referenced by an item (`heroImage`, images inside the markdown body):\n\n1. Stage the local file at a temporary public URL (or use a local-upload MCP tool if one is configured).\n2. `upload_media_from_url url=<staged-url> alt=<from frontmatter or filename>` — record the returned CDN URL.\n3. Substitute the CDN URL into both the `hero_image` field value AND the markdown-converted HTML body (regex replace `src=` references).\n\nKeep a local map (`./astro-migration-state.json`):\n\n```json\n{\n \"media\": {\n \"./hero.png\": \"https://cdn.typeroll.com/<orgId>/<siteId>/abc123.png\"\n }\n}\n```\n\nSo a partial run is resumable and you don't re-upload the same image twice.\n\n### 6. Batch-import items\n\nFor each file in `src/content/<collection>/`:\n\n```js\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport matter from 'gray-matter';\n\nconst files = fs.readdirSync('src/content/blog').filter(f => /\\.mdx?$/.test(f));\nfor (const file of files) {\n const raw = fs.readFileSync(`src/content/blog/${file}`, 'utf8');\n const { data, content } = matter(raw);\n const slug = file.replace(/\\.mdx?$/, '');\n const body = await md2html(content);\n\n // create_collection_item via the MCP\n await mcp.callTool('create_collection_item', {\n collection: 'blog',\n status: data.draft ? 'draft' : 'published',\n fields: {\n title: data.title,\n slug,\n description: data.description,\n date: data.pubDate ? new Date(data.pubDate).toISOString().slice(0, 10) : null,\n updated_date: data.updatedDate ? new Date(data.updatedDate).toISOString().slice(0, 10) : null,\n hero_image: mediaMap[data.heroImage] || data.heroImage,\n author: data.author,\n tags: (data.tags || []).join(', '),\n body,\n },\n });\n}\n```\n\nRate-limit awareness: spawn at most 5 parallel `create_collection_item` calls; the API caps at ~60 writes/minute and responds with 429 + `Retry-After` if exceeded.\n\nSet `status: 'draft'` (or honour Astro's `draft: true` frontmatter) for items the user should review before publishing. Only published items get static pages.\n\n### 7. Build the listing page\n\n```\ncreate_page title=\"Blog\" slug=\"blog\" status=\"published\" content_mode=\"html\"\n html_content=\"<section><h1>Blog</h1>\n <!-- typeroll:listing:blog -->\n <!-- /typeroll:listing:blog -->\n</section>\"\n\nregenerate_collection_listing\n collection=\"blog\"\n page_id=\"blog\"\n item_template=\"<article class=\\\"blog-card\\\"><a href=\\\"{{url}}\\\"><h2>{{title}}</h2><p>{{description}}</p><time>{{date}}</time></a></article>\"\n wrap_open=\"<div class=\\\"blog-grid\\\">\"\n wrap_close=\"</div>\"\n```\n\nSee `tr-blog` for full styling and post-import update flow.\n\n### 8. Translate standalone pages\n\nFor each non-dynamic `.astro` page under `src/pages/`:\n\n```\nread_partial partial_id=\"header\" # learn target's nav style\n# Re-skin the page content using target site's CSS variables and partials.\ncreate_page title=\"About\" slug=\"about\" status=\"draft\" content_mode=\"html\"\n html_content=\"...\"\n```\n\nDefault to **draft** — the user reviews each page before publishing.\n\nFor dynamic Astro pages (`[slug].astro` that consume a collection), you're already done: the matching Typeroll collection's `route_template` produces the same URLs at build time.\n\n### 9. Add redirects for URL changes\n\nIf Astro's slugs differed from what you derived for Typeroll (e.g. Astro had `/posts/my-article` but you want `/blog/my-article`), bulk-add redirects:\n\n```\nadd_redirect from=\"/posts/my-article\" to=\"/blog/my-article\" status=301\n```\n\nOr build a redirect map from the `astro-migration-state.json` and apply it in one pass.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\nBrowse the resulting site, compare against the Astro source, surface anything that drifted.\n\n## Astro-specific gotchas\n\n- **`.mdx` files with custom components.** Components inside MDX (`<MyCustom prop=\"...\" />`) will not render in Typeroll because the component definitions don't migrate. Two options: (1) replace each component with its rendered HTML output (run the Astro build, scrape the rendered HTML, use that as `body`); (2) if the component is a reusable visual element used across many items, factor it into a Typeroll partial and replace MDX usages with `<x-include name=\"...\" />` calls. See `tr-page-template` for the partial-include pattern.\n- **`src/content/config.ts` typed `image()` helper.** Astro resolves `image()` fields at build time to optimised assets. After migration the Typeroll `hero_image` field carries the original source URL — re-upload via step 5 to get it onto the Typeroll CDN.\n- **`getCollection` filters at runtime.** If `src/pages/blog/index.astro` does `getCollection('blog', ({ data }) => !data.draft)`, Typeroll handles this via `status` on the item. Don't translate the filter — set `status: 'draft'` on items where `data.draft === true`.\n- **Per-tag pages (`/blog/tag/[tag].astro`).** Typeroll doesn't auto-generate these. Either (a) drop tag pages and use a client-side filter in the blog listing JS, (b) generate them by enumerating unique tags and calling `create_page` per tag with a server-side-pre-filtered listing. (a) is preferable for ≤dozens of tags.\n- **`rehype-pretty-code` / shiki / fenced code with syntax highlighting.** The HTML output of these contains inline styles that the Typeroll sanitiser preserves. The colours match the Astro site's theme at import time; redoing the target design later means re-running the highlighter against the same markdown source. Keep a copy of the raw markdown if you might.\n- **`og:image` per page from `astro-og-image` style plugins.** Typeroll has its own SEO surface (`seo_title`, `seo_description`, `seo_og_image`). Set them explicitly on each page during step 8.\n- **i18n via `src/content/<lang>/<collection>/`.** Typeroll's site-level `default_language` + per-page `language` field cover this (capabilities: `supports_language_per_page: true`). Map each language directory to per-item `language: 'sv-SE' | 'en-US' | …` instead of separate collections.\n\n## Mixing imported + new content\n\nHalf-migrate, leave drafts, let the user inspect, iterate. You can:\n\n- Import only collections, skip standalone pages, let the user rebuild those from scratch.\n- Import everything as `status: 'draft'`, treat publishing as a per-item human review pass.\n- Mix sources: pull article bodies from Astro markdown, use Claude to generate fresh excerpts/SEO metadata before writing the item.\n\nKeep the local `astro-migration-state.json` honest — it's the only way to make a partial run resumable when the API rate-limits or a markdown parser trips on an edge case file.\n",
19
+ "tr-migrate-wp": "---\nname: tr-migrate-wp\ndescription: Use when the user asks to migrate a WordPress site to Typeroll, mentions wp-json, or names a WP source URL. Walks the WP REST API, rebuilds pages in the target site's design, transfers media, sets redirects, leaves everything as drafts for human review.\n---\n\n# Migrate from WordPress to Typeroll\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThe platform's in-portal migration workflow is the \"managed\" path for\ncustomers who want one-click. This skill is the \"power-user\" path: you\ndo it locally, mix data sources freely, and the user (consultant /\nagency) reviews each step in their terminal.\n\n## Preconditions\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The source WP site has `/wp-json` reachable (Google for \"wordpress\n REST API disabled\" if not — common for hardened hosts).\n- The Typeroll target site exists. New, blank sites with the\n starter design work best. If the target already has content, you\n must NOT clobber it — always `list_pages` first and only write to\n slugs that don't already exist.\n\n## Recipe\n\n### 1. Probe and inventory\n\n```\nfetch <wp-url>/wp-json # confirm REST is on\nfetch <wp-url>/wp-sitemap.xml or /sitemap.xml # URL inventory\n```\n\nBuild a list of every URL you intend to migrate. WP custom post types\nneed their REST endpoint (e.g. `/wp-json/wp/v2/news?per_page=100`),\nwalking `X-WP-TotalPages` to paginate.\n\n### 2. Learn the target's design\n\n```\nget_site\nread_site_settings # colors, fonts, voice cues\nlist_partials # header / footer / shared\nread_partial partial_id=\"header\" # nav structure\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative ids>] # see actual conventions\n```\n\nDon't skip this. Imposing a stranger's design on a customer's site is\nthe biggest avoidable mistake.\n\n### 3. Migrate one page at a time, draft status\n\nFor each source URL:\n\na. Fetch from WP. Prefer the helper plugin's authenticated endpoint\n (`/wp-json/typeroll/v1/...`) if available — it bypasses\n `show_in_rest=false` and returns ACF + builder fields. Otherwise\n fall back to `/wp-json/wp/v2/<post-type>?slug=<slug>`.\n\nb. Clean the HTML. Strip Elementor / Gutenberg / Breakdance class\n soup. Drop empty `<div>` and `<span>` wrappers. Keep semantic tags,\n tables, iframes from known hosts (YouTube / Vimeo / Calendly).\n\nc. Migrate referenced images:\n - For each `<img src>` and CSS `background-image: url()`:\n 1. Download the source image locally.\n 2. `create_upload_url filename=... content_type=...` → returns\n `{ upload_url, cdn_url, media_id }`.\n 3. PUT the bytes to `upload_url` (curl or fetch with the same\n content type).\n 4. Replace the `src` with `cdn_url` in the rewritten HTML.\n - Use `update_media media_id=... alt_text=\"...\"` to set a real alt\n text (existing WP `alt` attribute or `aria-label`; fall back to\n filename only as a last resort).\n\nd. Reconstruct in the target's design. The cleaned HTML is rarely\n ready to ship — typical fixes: replace WP `wp-block-*` classes\n with the target's CSS variables; turn Elementor sections into\n plain `<section>` with the target's spacing; fix headings so the\n page has exactly one `<h1>`. If you're confident, batch these\n through `bulk_replace_text` with `dry_run: true` first.\n\ne. Write the page as a draft:\n\n ```\n create_page title=\"...\" slug=\"<preserved-from-wp>\"\n html_content=\"<reconstructed>\"\n status=\"draft\" kind=\"article\" author=\"...\"\n seo_title=\"...\" seo_description=\"...\"\n ```\n\n **Preserve the source URL.** WP post URLs like\n `/2024/01/foo-bar/` go in as `slug: \"2024/01/foo-bar\"`. The\n slug supports slashes; encode the WP permalink structure verbatim\n when the customer wants existing links to keep working.\n\n### 4. Redirects\n\nAfter migration, every URL the agent didn't preserve verbatim needs a\nredirect:\n\n```\ncreate_redirect from_path=\"/old-services\" to_path=\"/services\"\n```\n\nWalk the inventory; for each URL: did it become a page with the same\npath? If yes, no redirect. If renamed, `create_redirect`. If\nintentionally dropped, mark it excluded in your notes (the customer\nshould sign off on every dropped URL).\n\n### 5. Preview + review with the user\n\n```\nget_preview_link page_id=<id> # one URL the user can click\n```\n\nOpen in the user's browser. The preview navigates the whole site from\none mint. Iterate on feedback: pages, header, footer.\n\n### 6. Ship\n\nWhen the user signs off:\n\n```\n# Bulk-publish drafts that look right\nbatch_update_pages updates=[{page_id, patch:{status:\"published\"}}, ...]\n\n# Deploy\ntrigger_deploy\nget_deploy_status job_id=<id> # poll\n```\n\n## Pitfalls\n\n- **Don't publish during migration.** Always import as `draft`. Even\n if the agent is confident, the customer needs the chance to spot-check.\n- **WP slugs sometimes drift.** A post saved with slug `foo-bar` may\n have been served at `/2024/01/foo-bar/` due to the permalink\n structure. The full URL is what users see in Google; preserve that,\n not the bare slug.\n- **Image bandwidth.** R2 upload is metered. Use `find_pages_matching`\n contains=\"<old-domain>\" on already-imported content to spot images\n that weren't transferred.\n- **WP-specific JSON-LD** (Yoast, Rank Math) is usually wrong after a\n redesign because it references old URLs. Strip it; let Typeroll\n emit fresh Article/Page schemas via `kind: 'article'` + `author`.\n\n## When the source isn't WordPress\n\nThe same shape applies for any source — Squarespace export, custom\nCMS, scraped HTML, CSV. Replace step 1's \"WP REST\" probe with whatever\ndiscovery the source supports, and the rest of the recipe is unchanged.\n",
20
+ "tr-new-site": "---\nname: tr-new-site\ndescription: 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]\".\n---\n\n# Bootstrap a new Typeroll site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nStart here when the site already exists as a database record (created via\nthe portal UI or API) but has no design, no header/footer, and no pages.\nThe goal is to go from blank to a working 4-page site with correct brand\nidentity in a single session.\n\n**Pages are built in block mode** — the platform default. Blocks give\nstructured, per-field editing, native full-bleed sections, responsive\nbreakpoints, and templates. HTML mode is the secondary path for\nhand-crafted one-offs and migrated content (section at the end).\n\n## Preconditions\n\n- `@typeroll/mcp-server` configured with a valid `TYPEROLL_API_KEY`.\n- The site exists (confirm with `get_site`).\n- You have the customer brief: company name, industry, 2–3 key brand colors,\n tone of voice, and a list of initial pages.\n\n## Recipe\n\n### 1. Audit current state\n\n```\nget_site\nget_site_capabilities # template_capabilities_version — what this deployment supports\nread_site_settings # see what (if anything) is already configured\nlist_pages # don't overwrite pages that already exist\nlist_partials # check if header/footer already have content\nlist_block_types # the per-site block palette — NEVER assume, always list\n```\n\n### 2. Brand + settings\n\nOne `update_site_settings` call with every field you know:\n\n```json\n{\n \"site_name\": \"Acme Studio\",\n \"tagline\": \"Short, punchy tagline\",\n \"language\": \"sv\",\n \"colors\": {\n \"primary\": \"#1a1a2e\",\n \"secondary\": \"#16213e\",\n \"accent\": \"#e94560\",\n \"background\": \"#f5f5f5\",\n \"surface\": \"#ffffff\",\n \"text\": \"#1a1a2e\",\n \"text_light\": \"#6b7280\"\n },\n \"fonts\": { \"heading\": \"Playfair Display\", \"body\": \"Inter\", \"size_base\": 16 },\n \"contact\": { \"email\": \"hej@acme.se\", \"phone\": \"+46 8 123 456\" },\n \"social\": { \"instagram\": \"https://instagram.com/acme\" }\n}\n```\n\nRead it back with `read_site_settings`. Google Fonts names are\ncase-sensitive display names (\"Plus Jakarta Sans\", not \"plus jakarta\").\n\n**Site icons are part of brand setup** — upload favicon (32–64px) and a\n180×180 apple touch icon, set `favicon` + `apple_touch_icon`. No icon\nassets? Derive a proposal (see `tr-brand`). Also set `settings.logo` to\nthe uploaded brand mark — it feeds OG/schema even if the header uses a\ndifferent lockup.\n\n### 3. Header + footer partials\n\n**Start from a vetted preset — don't hand-roll the layout.** `read_skill\ntr-header-footer` has robust header + footer presets (centered logo, logo+nav\nwith a no-JS mobile menu, centered + 3-column footers) that avoid the usual\ntraps: clipped logos (no `overflow:hidden` near the logo), distorted logos\n(`height` + `width:auto`), and broken mobile menus. Fill the placeholders and\nrestyle to the palette.\n\nPartials are usually simplest in HTML mode (one nav, a few links — no\nper-field editing needed). Keep them lean; literal site name (no template\nengine in partials):\n\n```html\n<header class=\"site-header\">\n <div class=\"header-inner\">\n <a class=\"header-logo\" href=\"/\"><img src=\"LOGO_MEDIA_URL\" alt=\"Acme Studio\" height=\"40\" /></a>\n <nav class=\"header-nav\">\n <a href=\"/om-oss\">Om oss</a>\n <a href=\"/kontakt\">Kontakt</a>\n </nav>\n </div>\n</header>\n<style>\n.site-header{background:var(--color-background);padding:1rem 2rem}\n.header-inner{max-width:1080px;margin:0 auto;display:flex;align-items:center;justify-content:space-between}\n.header-nav{display:flex;gap:2rem}\n.header-nav a{color:var(--color-text);text-decoration:none}\n</style>\n```\n\n`replace_partial partial_id=\"header\" html_content=\"...\"` — same pattern\nfor the footer. Design notes: **no border-bottom on the header if the\nfirst page section should meet it seamlessly** — let background color\nchanges do the separating. Anchor links in nav (`/#section`) are fine.\n\n### 4. Homepage — block tree\n\n`create_page` with the whole tree in one call. Omit block `id`s — the\nplatform assigns them (`blk_…`); you read them back for later\n`update_block` calls.\n\n```\ncreate_page title=\"Start\" slug=\"\" status=\"draft\" content_mode=\"blocks\" blocks=[...]\n```\n\nA proven landing-page skeleton (every top-level block is a `core/section`\n— sections are **natively full-bleed**: the background runs edge-to-edge,\ncontent is constrained by the section's own inner container via the\n`width` field. NEVER use 100vw negative-margin hacks. Anchor ids and\ncustom classes via `style_overrides` are safe on sections from\ntemplate_capabilities_version ≥ 0.15.3; on 0.14.x–0.15.2 they wrap the\nsection in a div and silently break full-bleed — there, put the anchor\non a block *inside* the section instead):\n\n```json\n[\n { \"type\": \"core/section\", \"data\": { \"background\": \"#ffffff\", \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/media_card\", \"data\": {\n \"image\": \"MEDIA_URL\", \"image_alt\": \"…\", \"image_side\": \"right\",\n \"heading\": \"Huvudrubriken\", \"heading_level\": \"h2\",\n \"text\": \"<p>Ingress …</p>\",\n \"button_label\": \"Kontakta oss\", \"button_url\": \"/#kontakt\"\n } }\n ] },\n { \"type\": \"core/section\", \"data\": { \"padding_y\": \"lg\" }, \"children\": [\n { \"type\": \"core/heading\", \"data\": { \"text\": \"Så funkar det\", \"level\": \"h2\", \"align\": \"center\" } },\n { \"type\": \"core/grid\", \"data\": { \"cols\": 3, \"gap\": \"lg\" }, \"children\": [\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"1\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"2\", \"title\": \"…\", \"text\": \"<p>…</p>\" } },\n { \"type\": \"core/step_card\", \"data\": { \"number\": \"3\", \"title\": \"…\", \"text\": \"<p>…</p>\" } }\n ] }\n ] },\n { \"type\": \"core/cta\", \"data\": {\n \"heading\": \"Redo att börja?\",\n \"primary_label\": \"Kontakta oss\", \"primary_url\": \"/kontakt\"\n } }\n]\n```\n\nBlock-palette guidance (verify against `list_block_types` — the source of\ntruth):\n\n- **Hero:** `core/media_card` inside a white section (image beside copy),\n or `core/hero` (eyebrow/heading/subheading + `primary_*`/`secondary_*`\n buttons — rendered server-side; `layout: split-right` puts the image\n beside the text). For a plain text hero: section + heading + prose +\n button.\n- **`core/heading`** decouples `level` (h1–h6, semantics) from `size`\n (visual) — exactly one `level: h1` per page.\n- **Images:** `core/image` with an uploaded media URL. The build pipeline\n automatically emits responsive `<picture>` with AVIF/WebP variants\n (run `generate_image_variants` after upload) — the in-portal preview\n shows a plain `<img>`, the deployed site gets the upgrade. Use the\n `radius` field for rounded corners.\n- **Repeaters/listings:** `core/collection_list`, `gallery`,\n `feature_grid` etc. — alias blocks over `core/repeater`. Use these for\n collection-driven content instead of hand-writing listing markup.\n- **Forms:** `create_form`, then a `core/html` block carrying the plain\n `<form method=\"POST\" action={submit_url}>` embed with the hidden\n `_token` — see `tr-forms`.\n- **`core/html`** is the escape hatch for the genuinely unique thing —\n not a default. If you reach for it more than once or twice per page,\n note why (that's block-library feedback).\n\nSlot containers (`core/columns`, `core/tabs`): populate them either by\npassing the whole tree inline (`block={ type: 'core/columns', slots:\n[[…],[…]] }`) or incrementally with `add_block parent_id=<columns-id>\nslot_index=0|1`. Requires template_capabilities_version ≥ 0.15.2 — on\nolder sites use `core/grid` (children flow into columns) instead.\n\nPartial last rows (template_capabilities_version ≥ 0.16.5): when N equal\ncards don't divide by the grid's column count (5 cards, 3 cols), set\n`last_row: 'center'` on the `core/grid` — the orphan row auto-centers.\nNEVER invent a \"wide\" variant of one peer card to fill the hole, and\ndon't hand-roll 6-column CSS tricks; both distort content to patch\nlayout. On older sites, pick a column count that divides N.\n\nIcons (template_capabilities_version ≥ 0.16.0): `type: 'icon'` fields on\n`core/icon`, `core/icon_box`, and `core/step_card` render inline SVG when\nthe value is a name from `get_site_capabilities → core_icon_names` (a\ncurated Lucide subset — `check`, `star`, `shield-check`, `mail`,\n`arrow-right`, `truck`, `chart-line`, …). Any other value (emoji, plain\ntext) renders as text, so emoji stand-ins keep working. Icons inherit\nsize from font-size and color from `currentColor`/the block's color\nfield. On older sites icons don't render — use emoji or CSS markers.\n\nKnown limitations (honest list — don't fight them):\n\n- **`core/tabs` label icons don't render** (the tab strip is built\n client-side without the icon pipeline). Text labels only.\n\nTheming: block primitives render neutral. Brand color/typography comes\nfrom settings (step 2). For page-specific polish (e.g. a colored card\ntreatment), a single `core/html` block with a small `<style>` scoped to\n`[data-bid]`/section selectors is acceptable — keep it minimal and note\nit in your log.\n\n### 5. Inner pages\n\nSame pattern: `create_page` with `content_mode: \"blocks\"` and a section\ntree. Standard set: Om oss, Tjänster, Kontakt — or what the brief says.\nDefault new pages to `status: \"draft\"`; publish after review.\n\n### 5b. If the legacy site is still live, scrape canonical content\n\nFor pages with canonical text (privacy policy, terms, about), `WebFetch`\nthe live page and carry the text verbatim — don't rewrite legal copy\nfrom memory. Convert to prose blocks (or one `core/html` for complex\nlegacy markup).\n\n### 6. Preview + iterate\n\n```\nget_preview_link # signed URL for browser review\nget_page_preview page_id=\"home\" # rendered HTML for structural checks\n```\n\n**Self-review the visuals before you call it done — appearance AND\nreadability, not just structure.** Screenshot the deployed/preview site at\ndesktop (~1440px) and mobile (~390px) and look: logo FULLY VISIBLE (not clipped by\na header's overflow:hidden) + legible + brand-compliant against its actual\nbackground — screenshot the header IN CONTEXT, not the logo element in isolation\n(an element shot hides layout clipping); a light wordmark must not sit bare on a\nlight surface. Text contrast everywhere, no horizontal scroll or mid-word\nbreaks, every image rendered, mobile layout actually collapsed. \"No overflow +\ncopy present\" is not a design review — never report a build as done/perfect off\nstructural metrics alone.\n\nShare the preview link. Iterate on feedback with `update_block` /\n`add_block` / `move_block` — that's the point of block mode: surgical\nedits, not full-page rewrites.\n\n### 7. Deploy\n\nWhen the user approves:\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Secondary path: HTML mode\n\nFor migrated legacy pages or a hand-crafted one-off, `content_mode:\n\"html\"` still works. Rules that apply there (and only there):\n\n- Wrap the body in a page-scope `<article class=\"my-page\">` and prefix\n selectors with it — the `.page-content` shell sets width/padding at\n normal specificity.\n- HTML-mode bodies are container-constrained; full-bleed requires the\n negative-margin escape (`margin-left: calc(50% - 50vw); width: 100vw`)\n — never combine with `overflow-x: clip` on a wrapper.\n- `set_page_mode` flips a page between modes;\n `convert_page_to_blocks` does a heuristic HTML→blocks conversion.\n\n## Pitfalls\n\n- **Don't create pages that already exist** — `list_pages` first; use\n `update_page` if the slug is taken.\n- **`update_page` takes a `patch` object**, not flat fields.\n- **Don't hardcode block field names from memory** — schemas are\n per-site (`list_block_types`/`read_block_type`).\n- **One `level: h1` per page** (core/heading) — SEO + screen readers.\n- **Don't simplify data during import** — preserve Swedish characters in\n labels (`affärsutveckling`, not the ASCII-folded slug), keep titles\n verbatim; slugs are derived for URLs only.\n- **Minimal JS.** Inline `<script>` in page content is stripped by the\n sanitizer. Interactivity ships via block-type `script` (requires the\n site's AI-scripts opt-in) or the human-managed `scripts_body_end`.\n",
21
21
  "tr-page-template": "---\nname: tr-page-template\ndescription: Use when several pages on a Typeroll site share the same outer structure — category pages, service-detail pages, landing-page variants — and the user wants to edit the shared bits in one place. Covers two flows: the HTML-mode pattern with partials + `<x-include>` (works everywhere, recommended for Phase 1), and the formal block-mode PageTemplate (when the site is in block mode).\n---\n\n# Share structure across pages\n\nWhen you have N pages that follow the same skeleton — say 7 category landing pages, each with `Hero → Intro → Features grid → CTA banner` — you don't want to edit 7 HTML bodies whenever the design changes. There are two ways to share structure in Typeroll, and the right pick depends on the site's content mode.\n\n## Decide which pattern fits\n\n| Site is mostly in… | Use |\n|---|---|\n| **HTML mode** (the Phase 1 default — most sites) | Partials + `<x-include>` — pattern A below |\n| **Block mode** (`supports_blocks_mode=true` and the page's `content_mode='blocks'`) | PageTemplate via `set_page_template` — pattern B below |\n\nYou can check the site's mode by reading any existing page — `content_mode` is a top-level field on the page doc.\n\nIf unsure, default to **pattern A**. It works on every Typeroll site and the migration to block-mode templates later is mechanical.\n\n## Pattern A — Partials + `<x-include>` (HTML mode)\n\nA partial is a named HTML fragment. Putting `<x-include name=\"my-partial\" />` anywhere in a page body inlines that fragment at build time. Editing the partial updates every page that references it on the next deploy.\n\n### Recipe for \"7 category pages with shared structure\"\n\n#### 1. Identify the shared chunks\n\nWalk through one category page and mark which sections are the same across all 7:\n\n```\n[Hero: title + tagline + image] ← varies per category\n[Intro paragraph] ← varies per category\n[Common: \"Why choose us\" three-tile band] ← identical across 7\n[Common: CTA banner with newsletter form] ← identical across 7\n[Common: footer testimonials] ← identical across 7\n```\n\nThree reusable bits: `why-choose-us`, `cta-newsletter`, `footer-testimonials`.\n\n#### 2. Create the partials\n\n```\ncreate_partial partial_id=\"why-choose-us\" html_content=\"<section class=\\\"why\\\">\n <div class=\\\"container\\\">\n <h2>Varför oss</h2>\n <div class=\\\"why__grid\\\">\n <div class=\\\"why__tile\\\"><h3>Erfarenhet</h3><p>20 år i branschen.</p></div>\n <div class=\\\"why__tile\\\"><h3>Kvalitet</h3><p>Vi mäter på allt.</p></div>\n <div class=\\\"why__tile\\\"><h3>Närhet</h3><p>Lokala kontor i tre städer.</p></div>\n </div>\n </div>\n</section>\n<style>\n.why{padding:4rem 0;background:var(--color-surface)}\n.why__grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:2rem;margin-top:2rem}\n.why__tile h3{font-family:var(--font-heading);margin-bottom:0.5rem}\n</style>\"\n```\n\nSame for `cta-newsletter` and `footer-testimonials`.\n\nThe partial-id is what `<x-include>` references — keep it kebab-case and descriptive.\n\n#### 3. Build each category page using the partials\n\n```\ncreate_page title=\"Tjänster för bostadsrätter\" slug=\"brf\" content_mode=\"html\"\n html_content=\"<section class=\\\"hero hero--brf\\\">\n <div class=\\\"container\\\">\n <h1>Tjänster för bostadsrättsföreningar</h1>\n <p class=\\\"hero__tagline\\\">Trygg förvaltning, helt utan överraskningar.</p>\n </div>\n</section>\n\n<section class=\\\"intro\\\">\n <div class=\\\"container\\\">\n <p>Vi har förvaltat över 200 BRF:er i Stockholmsområdet sedan 2005.</p>\n </div>\n</section>\n\n<x-include name=\\\"why-choose-us\\\" />\n<x-include name=\\\"cta-newsletter\\\" />\n<x-include name=\\\"footer-testimonials\\\" />\"\n```\n\nRepeat for the other 6 categories, varying only the `hero` + `intro` sections.\n\n#### 4. Edit once, propagate everywhere\n\nAdding a fourth \"Why us\" tile? Edit `why-choose-us` once:\n\n```\nreplace_partial partial_id=\"why-choose-us\" html_content=\"<section class=\\\"why\\\">\n ...four tiles instead of three...\n</section>\"\n```\n\nAll 7 pages pick up the change on the next deploy. No multi-page diff.\n\n### Edge cases\n\n- **`<x-include>` self-closes or has an explicit close.** Both forms work: `<x-include name=\"x\" />` and `<x-include name=\"x\"></x-include>`.\n- **Nested includes are NOT supported.** If `partial-a` references `<x-include name=\"partial-b\" />`, the inner reference is not expanded. Flatten the hierarchy at design time.\n- **Unknown partial → silently empty.** A typo in the `name` attribute removes the tag at expand time with no warning. Verify the partial id matches one returned by `list_partials`.\n- **Partial content is sanitised on save.** `<script>` tags get stripped at partial creation, then the inlined HTML is sanitised again when the page renders. Two passes; both intentional.\n\n## Pattern B — Formal PageTemplate (block mode)\n\nAvailable when the site is in block mode (`supports_blocks_mode=true` and the page's `content_mode='blocks'`). A PageTemplate is a `Block[]` tree with one or more `template_content_slot` blocks marking where the page's own blocks go.\n\n### Recipe\n\n#### 1. List existing templates\n\n```\nlist_page_templates\n```\n\nReturns templates already defined on the site.\n\n#### 2. Create / pick a template\n\nTemplates live as `Block[]` trees under `paths.pageTemplate(orgId, siteId, templateId)`. They're created via the portal UI for now (no dedicated MCP `create_page_template` tool yet — the chat-tool surface in `anthropic.ts` exposes list + set, not create).\n\nIf the template you want doesn't exist, ask the user to create it via **/app/sites/{id}/templates** in the portal, or use the block-mode-aware AI chat in the portal which knows how to write template trees.\n\n#### 3. Assign it to a page\n\n```\nset_page_template page_id=\"brf\" template_id=\"category-landing\"\n```\n\nThe renderer composes the template's blocks with the page's blocks at build time via `composePageWithTemplate` (replacing each `template_content_slot` with the page's own block tree). The page's CSS / theme / SEO settings are unchanged.\n\n#### 4. Editing the template propagates to every page\n\nThe same template id can be set on multiple pages. Editing the template updates them all on next deploy.\n\n### When B beats A\n\n- Block-mode editor surfaces template assignment as a dropdown in the page settings.\n- Templates compose with the block tree, so the page author can drop content blocks into named slots rather than writing HTML.\n- The renderer is aware of block CSS / JS bundling — assets per block type are aggregated automatically.\n\n### When A still beats B even in block mode\n\n- Need a small reusable HTML chunk in the middle of a block-mode page → still use `<x-include>` (block-mode bodies can contain free HTML blocks that include partials).\n- One-page change wanted without affecting siblings → just edit the page's blocks; templates are all-or-nothing.\n\n## Refactor: 7 already-existing pages into a shared partial\n\nIf the 7 pages already exist as separate HTML bodies with duplicated chunks:\n\n1. `read_page page_id=<one of them>` and identify the shared HTML literally — character-for-character chunks that repeat across all 7.\n2. `create_partial partial_id=<descriptive-name> html_content=\"<the shared chunk>\"`.\n3. For each of the 7 pages, `update_page` with the shared chunk replaced by `<x-include name=\"<name>\" />`.\n4. Deploy. The rendered output should be byte-identical to before; only the source pages got shorter.\n\nDon't try to abstract first time and hand-write the partials. Refactor from real, working duplication.\n\n## Pitfalls\n\n- **Don't put hero / page-specific content into a shared partial.** A partial is for things that are *truly identical* across uses. The moment you want it to vary by page, lift the differing parts back into the page body.\n- **Header and footer already use partials.** Don't recreate them inside a category-page shared partial — they're injected by the layout, not by the page body.\n- **CSS scoping.** Partials carrying `<style>` blocks merge into every page that includes them. If two partials define `.tile` differently, the last one wins. Either namespace classes per partial (`.why__tile`, `.feature__tile`) or move shared styles into the site's global CSS via `update_site_settings` → `custom_css`.\n- **Don't migrate to block-mode templates just because you can.** Phase 1 sites are HTML-mode by design; partials + includes give you 90% of the value with zero risk.\n- **`<x-include>` in a partial body referencing another partial.** Won't expand (see edge cases). If you find yourself wanting this, you're building a layout system inside the partial system — at that point the site probably wants block mode.\n",
22
- "tr-redesign-branch": "---\nname: tr-redesign-branch\ndescription: Use when the user asks to redesign, modernize, or restructure a Typeroll site (or a section of it). Forces branch-isolated work so the live site stays untouched until the redesign is approved.\n---\n\n# Redesign a site without breaking the live one\n\nSite-wide changes are exactly where copy-on-write branches earn their\nkeep. This skill enforces the discipline: every redesign happens on a\nbranch, preview-checked end-to-end, merged only after user sign-off.\n\n**Copy comes from the LIVE page, not a local draft.** A redesign changes\nthe design, not the words. Read the existing copy from the live page\n(`read_page`/`batch_read_pages`) and carry it over verbatim. Local\n`sources/*.md` files are drafts — use them only if the user explicitly\nsays \"apply the copy in `<file>`\". Don't invent new headlines, drop\nsections, or \"restore\" text from an old draft; a draft that had drifted\nfrom the live page once sent a whole redesign off the approved wording.\nWhen you must change a word, change it on the live page too and keep the\ndraft file in sync.\n\n**Restraint beats decoration.** Default to clean, purposeful design. Don't reach\nfor decorative motifs (suns, blobs, glows, mascots, confetti) to look \"graphic\" —\nunless a motif *means something for this brand/page*, it reads as random, and it's\nusually the exact thing that clips, seams, and crops. These fragile patterns broke\na real build — avoid them:\n- **A shape divider (wave/curve) between two sections** → don't hand-roll it in\n `core/html`; a separate stacked shape seams against the next section (a Chrome\n sub-pixel hairline). Use `core/section`'s **`divider_top` / `divider_bottom`**\n (`wave | curve | tilt`) — the platform paints it in the section's own colour and\n overlaps the neighbour by 1px, so it's seam-free by construction. Put the divider\n on the section whose colour should rise/dip into the neighbour.\n- **A glow/decoration inside an `overflow:hidden` box** → clipped to a hard edge.\n Put it in a non-clipped layer, or size it to fade out before the box edge.\n- **`object-fit:cover` on a portrait inside a circle/frame** → crops heads and\n faces. Use `contain`, reframe the source art, or size the frame to the art.\n- **A gradient \"fade\" at a section join** → reads as the design being cut off.\n Make transitions deliberate (a clean shape or a solid edge), never a fade.\nThese are invisible in a small full-page thumbnail and only show at real size in\nthe actual browser — see the review gate in step 6.\n\n## Recipe\n\n### 1. Discover (always)\n\n```\nget_site\nread_site_settings\nread_partial partial_id=\"header\"\nread_partial partial_id=\"footer\"\nlist_pages limit=20\nbatch_read_pages page_ids=[<top 3-5 pages>] # see actual conventions\nlist_partials # what free blocks exist\nlist_collections # any data we need to consider\n```\n\nWrite the user a short read-back: *\"This is a 12-page agency site\nusing CSS variables, primary color #1e40af, Inter heading + Source\nSans body. Existing pages are content-dense, single-column. Main nav\nhas 5 items including a CTA. I'd suggest...\"*\n\nConfirm direction before touching anything.\n\n### 2. Create a branch\n\n```\ncreate_branch name=\"<descriptive name>\"\n```\n\nSave the response's `id` — pass it as `version=<id>` on every\nsubsequent call. Branches default `robots_blocked: true` so a\nhalf-finished redesign won't be indexed.\n\n### 2b. Write the design spec (REQUIRED — before you build)\n\nEvery redesign branch MUST carry a written design spec. Without it the\ndesign choices live only in the agent's head, so a later \"just tweak the\nillustration style / palette\" means re-deriving everything by hand (this\ngap cost a real project a full reverse-engineering pass). Write it BEFORE\nbuilding so it guides the work, and keep it in sync as the design evolves.\n\nSave it as a markdown doc with the project (e.g. `design-spec.md`, or\n`prompts/design-system.md`) — or, if there's no local working dir, as an\nunlisted page on the branch. It must capture:\n\n- **Palette** — every role + hex (background, surface, primary, accent,\n text, borders), and where the variant *diverges* from the brand and why.\n- **Typography** — fonts + weights/sizes per role.\n- **Illustration / imagery style** — the exact image-gen prompt prefix\n (tone, palette, formspråk, framing rules), so the imagery can be\n regenerated or restyled on its own without touching layout. State the\n business/concept constraints the imagery must respect (a wrong-concept\n image is worse than none).\n- **Section structure** — the page's sections in order + each one's\n treatment (band colour, layout).\n- **Rationale** — one line per major choice: *why* this direction.\n\nWhen the user later says \"adjust just the illustrations\" or \"change the\npalette\", you edit the spec first, then apply — the spec is the source of\ntruth for the design intent.\n\n### 3. Iterate on the branch\n\nFor each redesign step:\n\na. Make the change with `?version=<branch-id>`. Updates here don't\n touch main:\n\n ```\n update_partial partial_id=\"header\" patch={...} version=\"<branch>\"\n update_page page_id=home patch={...} version=\"<branch>\"\n ```\n\nb. Preview after every meaningful change:\n\n ```\n get_preview_link page_id=home version=\"<branch>\"\n ```\n\n Send the URL to the user. The preview navigates the whole branch\n from one mint.\n\nc. Iterate on feedback. Common rounds: headline tightening, color\n tweaks, swapping hero images.\n\n### 4. Site-wide changes through partials, not pages\n\nIf the redesign touches every page (e.g. new global header, new\nfooter, new CTA bar) — edit a partial, not 23 pages. Before editing a\nshared block, check the blast radius:\n\n```\nfind_pages_using_block partial_id=\"header\" version=\"<branch>\"\n```\n\nThis returns every page that would show the change. Communicate that\nto the user before the save.\n\n### 5. Bulk content cleanups via dry-run first\n\nIf the redesign requires content rewrites (e.g. \"remove every mention\nof the old company name\"), use the bulk tool with dry-run:\n\n```\nsearch_pages contains=\"OldCo\" version=\"<branch>\"\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=true version=\"<branch>\"\n# Show the user the sample_diffs\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=false version=\"<branch>\"\n```\n\n### 6. Approval round\n\n**Self-review is a multi-DIMENSION pass, not a glance — and most of it you\nMEASURE, not eyeball.** Structural checks (copy present, images return 200) are\nNOT a design review; never report \"approved\" off them. Don't just list the bugs\nyou happened to notice — walk every dimension below on the DB-live preview\n(a reused `get_preview_link`; browser tool + DOM reads), fix what you find,\nreload, re-check. No re-deploy between fixes — the preview renders from the DB.\n\n**Use `tr-design-review` (`read_skill tr-design-review`) for the HOW** — it has\nthe per-dimension measurement snippets (overflow ladder, computed contrast, touch\ntargets, the anti-lazy-load broken-image check) and the scorecard + verdict\nformat. The dimension summary below is the \"what\"; that skill is the runnable\nroutine. In particular: scroll-and-settle to trigger lazy images BEFORE any\nfull-page screenshot, or you'll report blank boxes that aren't real.\n\n1. **Responsive** — screenshot across a width ladder (mobile / tablet / laptop /\n desktop / wide ≈390 / 768 / 1024 / 1440 / 1920px) AND sweep the page's own\n `@media` breakpoints (read the page-scoped `<style>`; resize a few px below +\n above each). At EVERY width: `document.documentElement.scrollWidth <=\n clientWidth` (no horizontal scroll), grids flip cleanly, nothing squished /\n orphaned / overlapping, no mid-word breaks. Two sizes is not enough — bugs hide\n in between. Also check a short/landscape viewport and 200% browser zoom.\n2. **Visual & brand** — logo FULLY VISIBLE (not clipped by a header\n `overflow:hidden` + overlap margin) and brand-compliant; screenshot the header\n IN CONTEXT, never the logo element in isolation (that hides clipping).\n Decoration robust at real size in the real browser: no hairline seam at a\n divider (use `core/section` `divider_top`/`divider_bottom` — don't hand-roll a\n band), no glow clipped to a hard edge, no `object-fit:cover` cropping faces, no\n gradient fade-cutoff. Typography: body line-length ~45–75ch, consistent scale,\n no awkward widows on headings. Palette adherence (no off-brand colours);\n consistent spacing / alignment / radius / shadow.\n3. **Accessibility — MEASURE, don't eyeball** — compute actual contrast ratios\n (WCAG AA: body ≥4.5:1, large/UI ≥3:1) and fix failures by deepening the\n offending colour token; meaningful `alt` on every image; exactly one `<h1>` +\n no skipped heading levels; visible `:focus-visible` on every interactive\n element; every input has an associated `<label>`; touch targets ≥44px on\n mobile; semantic landmarks (header/nav/main/footer) + nav `aria-label`; honour\n `prefers-reduced-motion`.\n4. **Functional** — the form actually works (POST action correct, hidden token\n non-empty, honeypot present + hidden; a long name/email doesn't break layout);\n every link + in-page anchor resolves (each `#anchor` has a matching `id`; no\n `href=\"#\"`/`\"\"`); ZERO console errors/warnings; interactions (menu toggle,\n hover/focus/active) work.\n5. **Content** — no unrendered `{{…}}` tokens in the DOM; no placeholder/lorem;\n copy still matches the source of truth (the live page) verbatim.\n6. **Findable (SEO/meta)** — `<title>` + meta description present + sensible;\n `og:title`/`og:description`/`og:image`; canonical; favicon + apple-touch-icon;\n `<html lang>`; `noindex` correct (branches must be noindex).\n7. **Fast (performance)** — images at sane sizes (not a 2048px file shown at\n 380px without a responsive variant), modern format (avif/webp), `width`/`height`\n or aspect-ratio set (no layout shift), below-fold lazy / above-fold eager.\n8. **Cross-browser** — the same CSS renders differently per engine (the divider\n seam was Chrome-only; WebKit/Firefox have their own). Re-check in another engine\n if you can; if only Chromium is available, statically flag risky props\n (`backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on mobile →\n prefer `100svh`, `sticky` inside `overflow`).\n\nFix what you find and re-check before involving the user. \"Looks structurally\nfine\" ≠ \"looks good\", and \"looks good in Chrome at 1440\" ≠ \"works for everyone,\neverywhere\" — never report a design as approved/perfect off a glance or a partial\npass.\n\nThen give the user the **DB-live preview link** to review — and use the SAME\nlink for your own verification:\n\n- **While iterating (default):** a reused `get_preview_link` (mint once,\n reuse — defaults to a 24h TTL). It renders from the database with NO build, so every\n edit shows on reload, and one link navigates the whole branch (internal\n links keep the token). The token URL is stable across edits — re-mint only\n when the 24h lapses, never per edit. Do NOT deploy just to let the user (or\n yourself) see a change.\n- **When they want the COMPILED static site** (a permanent bookmark, a\n stakeholder link to the built output, or a final pre-merge check): deploy\n the branch once (`trigger_deploy version=\"<branch>\"`) and share the stable\n alias `https://<branch>.<project>.pages.dev` (the `<project>` is the part\n after the hash in the returned `deploy_url`). Branch deploys are\n `robots_blocked`, so it won't be indexed. The immutable per-deploy\n `<hash>.pages.dev` is for your own one-off checks (a new hash each deploy).\n\nDefault: review/iterate on the reused DB-live preview; deploy only for the\ncompiled output or merge. Wait for an explicit \"looks good, ship it.\"\n\n### 7. Merge + deploy\n\n```\nmerge_branch version_id=\"<branch>\" # branch's diffs land on main\ntrigger_deploy\nget_deploy_status job_id=<id> # poll until succeeded\n```\n\nOptionally, after a successful deploy:\n\n```\ndelete_branch version_id=\"<branch>\" # tidy up\n```\n\n(You can also leave the branch around as a record of the redesign;\ndisk cost is tiny.)\n\n## Pitfalls\n\n- **Forgetting `version=` on writes.** Every call you make on the\n branch must include `version=<branch-id>`. A missing one writes\n straight to main — silent and bad.\n- **Skipping discovery.** \"Modernize\" without first reading the site\n produces a confidently-out-of-place result. Always sample existing\n pages.\n- **Deploying to preview.** Don't `trigger_deploy` after every edit just to\n see the change — that builds static pages and the URL is only as fresh as\n the last build. Iterate on a reused DB-live `get_preview_link` (renders from\n the DB, reflects edits on reload); deploy only for the compiled static\n output or merge (steps 6–7).\n- **Auto-merge.** Don't `merge_branch` without explicit user sign-off.\n Once merged, the only undo is another branch + reverse edits.\n- **Header rewrites that drop the brand block.** Even when the\n redesign is dramatic, preserve the brand mark + the nav skeleton\n unless the user said to redo them.\n\n## When to NOT use a branch\n\nTiny edits — \"fix the typo on the About page\" — don't need a branch.\nThe in-portal chat handles those directly on main. This skill is for\nwork where:\n\n- The user might want to walk away mid-redesign and come back later\n- Multiple changes need to ship together\n- The site is high-traffic and \"broken for an hour\" is unacceptable\n- Stakeholder review across multiple pages is expected\n\nIf none of those apply, edit main directly and move on.\n",
22
+ "tr-redesign-branch": "---\nname: tr-redesign-branch\ndescription: Use when the user asks to redesign, modernize, or restructure a Typeroll site (or a section of it). Forces branch-isolated work so the live site stays untouched until the redesign is approved.\n---\n\n# Redesign a site without breaking the live one\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nSite-wide changes are exactly where copy-on-write branches earn their\nkeep. This skill enforces the discipline: every redesign happens on a\nbranch, preview-checked end-to-end, merged only after user sign-off.\n\n**Copy comes from the LIVE page, not a local draft.** A redesign changes\nthe design, not the words. Read the existing copy from the live page\n(`read_page`/`batch_read_pages`) and carry it over verbatim. Local\n`sources/*.md` files are drafts — use them only if the user explicitly\nsays \"apply the copy in `<file>`\". Don't invent new headlines, drop\nsections, or \"restore\" text from an old draft; a draft that had drifted\nfrom the live page once sent a whole redesign off the approved wording.\nWhen you must change a word, change it on the live page too and keep the\ndraft file in sync.\n\n**Restraint beats decoration.** Default to clean, purposeful design. Don't reach\nfor decorative motifs (suns, blobs, glows, mascots, confetti) to look \"graphic\" —\nunless a motif *means something for this brand/page*, it reads as random, and it's\nusually the exact thing that clips, seams, and crops. These fragile patterns broke\na real build — avoid them:\n- **A shape divider (wave/curve) between two sections** → don't hand-roll it in\n `core/html`; a separate stacked shape seams against the next section (a Chrome\n sub-pixel hairline). Use `core/section`'s **`divider_top` / `divider_bottom`**\n (`wave | curve | tilt`) — the platform paints it in the section's own colour and\n overlaps the neighbour by 1px, so it's seam-free by construction. Put the divider\n on the section whose colour should rise/dip into the neighbour.\n- **A glow/decoration inside an `overflow:hidden` box** → clipped to a hard edge.\n Put it in a non-clipped layer, or size it to fade out before the box edge.\n- **`object-fit:cover` on a portrait inside a circle/frame** → crops heads and\n faces. Use `contain`, reframe the source art, or size the frame to the art.\n- **A gradient \"fade\" at a section join** → reads as the design being cut off.\n Make transitions deliberate (a clean shape or a solid edge), never a fade.\nThese are invisible in a small full-page thumbnail and only show at real size in\nthe actual browser — see the review gate in step 6.\n\n## Recipe\n\n### 1. Discover (always)\n\n```\nget_site\nread_site_settings\nread_partial partial_id=\"header\"\nread_partial partial_id=\"footer\"\nlist_pages limit=20\nbatch_read_pages page_ids=[<top 3-5 pages>] # see actual conventions\nlist_partials # what free blocks exist\nlist_collections # any data we need to consider\n```\n\nWrite the user a short read-back: *\"This is a 12-page agency site\nusing CSS variables, primary color #1e40af, Inter heading + Source\nSans body. Existing pages are content-dense, single-column. Main nav\nhas 5 items including a CTA. I'd suggest...\"*\n\nConfirm direction before touching anything.\n\n### 2. Create a branch\n\n```\ncreate_branch name=\"<descriptive name>\"\n```\n\nSave the response's `id` — pass it as `version=<id>` on every\nsubsequent call. Branches default `robots_blocked: true` so a\nhalf-finished redesign won't be indexed.\n\n### 2b. Write the design spec (REQUIRED — before you build)\n\nEvery redesign branch MUST carry a written design spec. Without it the\ndesign choices live only in the agent's head, so a later \"just tweak the\nillustration style / palette\" means re-deriving everything by hand (this\ngap cost a real project a full reverse-engineering pass). Write it BEFORE\nbuilding so it guides the work, and keep it in sync as the design evolves.\n\nSave it as a markdown doc with the project (e.g. `design-spec.md`, or\n`prompts/design-system.md`) — or, if there's no local working dir, as an\nunlisted page on the branch. It must capture:\n\n- **Palette** — every role + hex (background, surface, primary, accent,\n text, borders), and where the variant *diverges* from the brand and why.\n- **Typography** — fonts + weights/sizes per role.\n- **Illustration / imagery style** — the exact image-gen prompt prefix\n (tone, palette, formspråk, framing rules), so the imagery can be\n regenerated or restyled on its own without touching layout. State the\n business/concept constraints the imagery must respect (a wrong-concept\n image is worse than none).\n- **Section structure** — the page's sections in order + each one's\n treatment (band colour, layout).\n- **Rationale** — one line per major choice: *why* this direction.\n\nWhen the user later says \"adjust just the illustrations\" or \"change the\npalette\", you edit the spec first, then apply — the spec is the source of\ntruth for the design intent.\n\n### 3. Iterate on the branch\n\nFor each redesign step:\n\na. Make the change with `?version=<branch-id>`. Updates here don't\n touch main:\n\n ```\n update_partial partial_id=\"header\" patch={...} version=\"<branch>\"\n update_page page_id=home patch={...} version=\"<branch>\"\n ```\n\nb. Preview after every meaningful change:\n\n ```\n get_preview_link page_id=home version=\"<branch>\"\n ```\n\n Send the URL to the user. The preview navigates the whole branch\n from one mint.\n\nc. Iterate on feedback. Common rounds: headline tightening, color\n tweaks, swapping hero images.\n\n### 4. Site-wide changes through partials, not pages\n\nIf the redesign touches every page (e.g. new global header, new\nfooter, new CTA bar) — edit a partial, not 23 pages. Before editing a\nshared block, check the blast radius:\n\n```\nfind_pages_using_block partial_id=\"header\" version=\"<branch>\"\n```\n\nThis returns every page that would show the change. Communicate that\nto the user before the save.\n\n### 5. Bulk content cleanups via dry-run first\n\nIf the redesign requires content rewrites (e.g. \"remove every mention\nof the old company name\"), use the bulk tool with dry-run:\n\n```\nsearch_pages contains=\"OldCo\" version=\"<branch>\"\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=true version=\"<branch>\"\n# Show the user the sample_diffs\nbulk_replace_text pattern=\"OldCo\" replacement=\"NewCo\" dry_run=false version=\"<branch>\"\n```\n\n### 6. Approval round\n\n**Self-review is a multi-DIMENSION pass, not a glance — and most of it you\nMEASURE, not eyeball.** Structural checks (copy present, images return 200) are\nNOT a design review; never report \"approved\" off them. Don't just list the bugs\nyou happened to notice — walk every dimension below on the DB-live preview\n(a reused `get_preview_link`; browser tool + DOM reads), fix what you find,\nreload, re-check. No re-deploy between fixes — the preview renders from the DB.\n\n**Use `tr-design-review` (`read_skill tr-design-review`) for the HOW** — it has\nthe per-dimension measurement snippets (overflow ladder, computed contrast, touch\ntargets, the anti-lazy-load broken-image check) and the scorecard + verdict\nformat. The dimension summary below is the \"what\"; that skill is the runnable\nroutine. In particular: scroll-and-settle to trigger lazy images BEFORE any\nfull-page screenshot, or you'll report blank boxes that aren't real.\n\n1. **Responsive** — screenshot across a width ladder (mobile / tablet / laptop /\n desktop / wide ≈390 / 768 / 1024 / 1440 / 1920px) AND sweep the page's own\n `@media` breakpoints (read the page-scoped `<style>`; resize a few px below +\n above each). At EVERY width: `document.documentElement.scrollWidth <=\n clientWidth` (no horizontal scroll), grids flip cleanly, nothing squished /\n orphaned / overlapping, no mid-word breaks. Two sizes is not enough — bugs hide\n in between. Also check a short/landscape viewport and 200% browser zoom.\n2. **Visual & brand** — logo FULLY VISIBLE (not clipped by a header\n `overflow:hidden` + overlap margin) and brand-compliant; screenshot the header\n IN CONTEXT, never the logo element in isolation (that hides clipping).\n Decoration robust at real size in the real browser: no hairline seam at a\n divider (use `core/section` `divider_top`/`divider_bottom` — don't hand-roll a\n band), no glow clipped to a hard edge, no `object-fit:cover` cropping faces, no\n gradient fade-cutoff. Typography: body line-length ~45–75ch, consistent scale,\n no awkward widows on headings. Palette adherence (no off-brand colours);\n consistent spacing / alignment / radius / shadow.\n3. **Accessibility — MEASURE, don't eyeball** — compute actual contrast ratios\n (WCAG AA: body ≥4.5:1, large/UI ≥3:1) and fix failures by deepening the\n offending colour token; meaningful `alt` on every image; exactly one `<h1>` +\n no skipped heading levels; visible `:focus-visible` on every interactive\n element; every input has an associated `<label>`; touch targets ≥44px on\n mobile; semantic landmarks (header/nav/main/footer) + nav `aria-label`; honour\n `prefers-reduced-motion`.\n4. **Functional** — the form actually works (POST action correct, hidden token\n non-empty, honeypot present + hidden; a long name/email doesn't break layout);\n every link + in-page anchor resolves (each `#anchor` has a matching `id`; no\n `href=\"#\"`/`\"\"`); ZERO console errors/warnings; interactions (menu toggle,\n hover/focus/active) work.\n5. **Content** — no unrendered `{{…}}` tokens in the DOM; no placeholder/lorem;\n copy still matches the source of truth (the live page) verbatim.\n6. **Findable (SEO/meta)** — `<title>` + meta description present + sensible;\n `og:title`/`og:description`/`og:image`; canonical; favicon + apple-touch-icon;\n `<html lang>`; `noindex` correct (branches must be noindex).\n7. **Fast (performance)** — images at sane sizes (not a 2048px file shown at\n 380px without a responsive variant), modern format (avif/webp), `width`/`height`\n or aspect-ratio set (no layout shift), below-fold lazy / above-fold eager.\n8. **Cross-browser** — the same CSS renders differently per engine (the divider\n seam was Chrome-only; WebKit/Firefox have their own). Re-check in another engine\n if you can; if only Chromium is available, statically flag risky props\n (`backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on mobile →\n prefer `100svh`, `sticky` inside `overflow`).\n\nFix what you find and re-check before involving the user. \"Looks structurally\nfine\" ≠ \"looks good\", and \"looks good in Chrome at 1440\" ≠ \"works for everyone,\neverywhere\" — never report a design as approved/perfect off a glance or a partial\npass.\n\nThen give the user the **DB-live preview link** to review — and use the SAME\nlink for your own verification:\n\n- **While iterating (default):** a reused `get_preview_link` (mint once,\n reuse — defaults to a 24h TTL). It renders from the database with NO build, so every\n edit shows on reload, and one link navigates the whole branch (internal\n links keep the token). The token URL is stable across edits — re-mint only\n when the 24h lapses, never per edit. Do NOT deploy just to let the user (or\n yourself) see a change.\n- **When they want the COMPILED static site** (a permanent bookmark, a\n stakeholder link to the built output, or a final pre-merge check): deploy\n the branch once (`trigger_deploy version=\"<branch>\"`) and share the stable\n alias `https://<branch>.<project>.pages.dev` (the `<project>` is the part\n after the hash in the returned `deploy_url`). Branch deploys are\n `robots_blocked`, so it won't be indexed. The immutable per-deploy\n `<hash>.pages.dev` is for your own one-off checks (a new hash each deploy).\n\nDefault: review/iterate on the reused DB-live preview; deploy only for the\ncompiled output or merge. Wait for an explicit \"looks good, ship it.\"\n\n### 7. Merge + deploy\n\n```\nmerge_branch version_id=\"<branch>\" # branch's diffs land on main\ntrigger_deploy\nget_deploy_status job_id=<id> # poll until succeeded\n```\n\nOptionally, after a successful deploy:\n\n```\ndelete_branch version_id=\"<branch>\" # tidy up\n```\n\n(You can also leave the branch around as a record of the redesign;\ndisk cost is tiny.)\n\n## Pitfalls\n\n- **Forgetting `version=` on writes.** Every call you make on the\n branch must include `version=<branch-id>`. A missing one writes\n straight to main — silent and bad.\n- **Skipping discovery.** \"Modernize\" without first reading the site\n produces a confidently-out-of-place result. Always sample existing\n pages.\n- **Deploying to preview.** Don't `trigger_deploy` after every edit just to\n see the change — that builds static pages and the URL is only as fresh as\n the last build. Iterate on a reused DB-live `get_preview_link` (renders from\n the DB, reflects edits on reload); deploy only for the compiled static\n output or merge (steps 6–7).\n- **Auto-merge.** Don't `merge_branch` without explicit user sign-off.\n Once merged, the only undo is another branch + reverse edits.\n- **Header rewrites that drop the brand block.** Even when the\n redesign is dramatic, preserve the brand mark + the nav skeleton\n unless the user said to redo them.\n\n## When to NOT use a branch\n\nTiny edits — \"fix the typo on the About page\" — don't need a branch.\nThe in-portal chat handles those directly on main. This skill is for\nwork where:\n\n- The user might want to walk away mid-redesign and come back later\n- Multiple changes need to ship together\n- The site is high-traffic and \"broken for an hour\" is unacceptable\n- Stakeholder review across multiple pages is expected\n\nIf none of those apply, edit main directly and move on.\n",
23
23
  "tr-responsive": "---\nname: tr-responsive\ndescription: Use when a layout must behave differently at different screen sizes — different grid columns per breakpoint, an icon-box that's icon-on-top on mobile but icon-left on tablet, hiding a block on small screens, fluid type. Triggers on \"responsive\", \"mobile/tablet/desktop layout\", \"stack on mobile\", \"X columns on desktop and Y on mobile\", \"olika på mobil/surfplatta\", \"responsivt\".\n---\n\n# Make a Typeroll block layout responsive\n\nTyperoll has a built-in five-breakpoint system. You almost never hand-write\nmedia queries — you set per-breakpoint values on responsive fields and the\nrenderer compiles the `@media` rules per block instance.\n\n## The five breakpoints (mobile-first)\n\n`mobile (<640) · tablet (≥640) · laptop (≥1024) · desktop (≥1280) · wide (≥1536)`\n\nA responsive field takes either a scalar (applies everywhere) or a sparse\nobject `{ mobile?, tablet?, laptop?, desktop?, wide? }`. Missing breakpoints\ninherit from the next smaller one. So you only set the breakpoints that change.\n\n## Setting per-breakpoint values\n\nUse `set_block_responsive` (or pass the object form directly in `add_block` /\n`update_block` data). `read_block_type <id>` tells you which fields are\n`responsive`.\n\n```\n# 4 columns on desktop, 2 on tablet, 1 on mobile:\nset_block_responsive target={kind:page,id:home} block_id=<grid-id>\n field=cols value={ mobile: 1, tablet: 2, desktop: 4 }\n\n# icon-box: icon on top on phones, beside the text on tablet+:\nset_block_responsive ... block_id=<iconbox-id>\n field=layout value={ mobile: \"icon-top\", tablet: \"icon-left\" }\n```\n\nPass a scalar to collapse a field back to one value everywhere.\n\n### Worked example — the classic feature grid\n\n\"4 cards/row with icon-on-top on desktop, 2/row with icon-left on a landscape\niPad, 1/row icon-on-top on a phone\":\n\n1. `core/grid` containing `core/icon_box` cards (or a `core/repeater` with\n `item_block: core/icon_box` for a collection-driven list).\n2. On the grid: `cols = { mobile: 1, tablet: 2, desktop: 4 }`.\n3. On each icon_box (or the repeater's item defaults):\n `layout = { mobile: \"icon-top\", tablet: \"icon-left\", desktop: \"icon-top\" }`.\n\nNo media queries authored — the build emits per-instance `@media` blocks and\nthe editor preview honours them. Flip the device toggle in the editor header\n(Mobil / Mobil-liggande / iPad / iPad-liggande / Desktop) to author and verify\neach breakpoint.\n\n## Hiding a block at some sizes\n\n`Block.hidden_on: Breakpoint[]` is universal — no per-block opt-in. E.g.\n`hidden_on: [\"mobile\"]` drops the block below 640px. Use it instead of building\na \"mobile-only\" duplicate.\n\n## Authoring a CUSTOM block type that's responsive\n\nTwo halves, BOTH required (`create_block_type` / `update_block_type`):\n\n1. Mark the field `responsive: true`.\n2. Expose it on the **outermost** template element as a CSS variable:\n `style=\"--{field}:{{field}}\"`, then read `var(--{field})` in the block CSS.\n\nIf the field's value is directly usable CSS (e.g. `direction: row|column` →\n`flex-direction: var(--direction)`), you're done.\n\nIf it's a friendly **token** that maps to CSS (e.g. `layout: icon-left` →\n`flex-direction: row`), add a `responsive_css` map on the field — otherwise the\nper-breakpoint overrides silently do nothing (a `[style*=\"--field:token\"]`\nselector can't see a `@media` override):\n\n```\n{ name: \"layout\", type: \"select\", options: [\"icon-top\",\"icon-left\"],\n default: \"icon-top\", responsive: true,\n responsive_css: { \"icon-top\": \"--dir: column;\", \"icon-left\": \"--dir: row;\" } }\n```\n\nThen the block CSS reads `flex-direction: var(--dir, column)`.\n\n## Fluid type — usually automatic\n\n`core/heading` and prose already use `clamp()` to scale smoothly between mobile\nand desktop. `core/heading` separates semantic `level` (h1–h6, for SEO) from\nvisual `size` (sm–3xl/auto) — \"h1 but only as big as an h3\" is one field, no\nbreakpoints needed.\n\n## Gotchas\n\n- Setting a value only at `desktop` leaves smaller screens on the field\n *default*, not on your value — set `mobile` too if you want a non-default\n baseline (mobile-first).\n- The editor preview width is approximate on a narrow panel, but the breakpoint\n you're editing is exact. Trust the deployed site / a wider window for `wide`.\n- **Never paper over horizontal overflow with `html,body{overflow-x:hidden}`.**\n Setting `overflow-x:hidden` on `html` forces `overflow-y` to compute as `auto`\n (CSS spec), turning `<html>` into a fixed-height nested scroller — the page\n then won't scroll normally (`window.scrollY` sticks at 0) and renders blank\n below the fold. Instead, find the element that overflows (a fixed width, a\n `transform:rotate` card poking out, a decorative `::before`/`::after`, a grid\n that didn't collapse) and fix THAT element's width / clip it with\n `overflow:hidden` on its own section. Verify with\n `document.documentElement.scrollWidth === clientWidth` at 360–390px.\n- **`core/grid` `stack_at` may not collapse on mobile** (a known platform bug):\n the block writes `style=\"--cols:N\"` inline, and an inline custom property beats\n the media query that tries to set `--cols:1`, so the grid stays N-up and text\n wraps a letter per line. Workaround until fixed: in page-scoped CSS override the\n real property, e.g. `@media(max-width:640px){.my-section [data-block=\"grid\"]{grid-template-columns:1fr!important}}`.\n- Background design reference: `docs/responsive-blocks.md` in the platform repo.\n",
24
- "tr-seo": "---\nname: tr-seo\ndescription: Use when the user asks to improve SEO, fix meta tags, add structured data, check page titles, or audit the site's search visibility. Triggers on \"SEO\", \"meta descriptions\", \"Google ranking\", \"structured data\", \"JSON-LD\", \"sitemap\", \"sökoptimering\", or \"hjälp mig synas på Google\".\n---\n\n# SEO audit and improvements for a Typeroll site\n\n## What Typeroll handles automatically\n\n- `<html lang>` from site `language` setting (per-page override via `language` field)\n- `<title>` = `page.seo_title || page.title + settings.default_seo_suffix`\n- `<meta name=\"description\">` from `page.seo_description`\n- `<meta name=\"robots\">` from `page.noindex`\n- `<meta property=\"og:*\">` Open Graph tags from seo_title, seo_description, og_image\n- `<link rel=\"canonical\">` from `page.canonical_url` (falls back to the page's own URL)\n- Article schema from `kind: \"article\"` + `author` + `date_published`\n- Page schema from `kind: \"page\"` (default)\n- `robots.txt` from `settings.robots_txt`\n- Sanitized HTML that preserves semantic structure\n\n## Recipe\n\n### 1. Audit current state\n\n```\nlist_pages status=\"all\"\nread_site_settings\n```\n\nFor each page, check:\n- Is `seo_title` set? (if not, Google uses `title` + suffix — often fine)\n- Is `seo_description` set? (150–160 chars, unique per page, includes keywords)\n- Is `og_image` set for the homepage and key landing pages?\n- Does the page have exactly one `<h1>`?\n\n### 2. Fix missing meta descriptions\n\n```\nbatch_update_pages updates=[\n {page_id: \"home\", patch: {seo_description: \"Acme designar rum...\"}},\n {page_id: \"om-oss\", patch: {seo_description: \"Vi är ett...\"}},\n {page_id: \"tjanster\", patch: {seo_description: \"Våra tjänster...\"}}\n]\n```\n\nGuidelines:\n- 150–160 characters\n- Include the most important keyword naturally\n- Make it a compelling reason to click, not a summary of the page's nav\n\n### 3. Fix page titles\n\nSEO title = what Google shows in search results.\n\nIf `settings.default_seo_suffix` is set (e.g. \" — Acme Studio\"), every\npage whose `seo_title` is empty will show `title + suffix`. That's usually\nfine for inner pages; set an explicit `seo_title` only when you want\nsomething different.\n\n```\nupdate_site_settings {\"default_seo_suffix\": \" — Acme Studio\"}\n\nupdate_page page_id=\"home\" patch={\n \"seo_title\": \"Acme Studio — Inredningsdesign i Stockholm\"\n}\n```\n\n### 4. Add Open Graph images\n\nSet `og_image` on pages that get shared on social media. If the site has\na branded hero image, upload it:\n\n```\nupload_media_from_url url=\"https://...\" alt=\"Acme Studio — Inredningsdesign\"\n# → returns cdn_url\n\nbatch_update_pages updates=[\n {page_id: \"home\", patch: {og_image: \"<cdn_url>\"}},\n {page_id: \"om-oss\", patch: {og_image: \"<cdn_url>\"}}\n]\n```\n\nOG image dimensions: 1200×630px ideal. The platform doesn't resize —\nuse a correctly-sized source image.\n\n### 5. Add structured data (JSON-LD)\n\nTyperoll auto-generates Article and Page schema, but you can override or\nextend with custom JSON-LD per page. Example: LocalBusiness on the homepage.\n\n```\nupdate_page page_id=\"home\" patch={\n \"json_ld\": \"{\\\"@context\\\":\\\"https://schema.org\\\",\\\"@type\\\":\\\"LocalBusiness\\\",\\\"name\\\":\\\"Acme Studio\\\",\\\"url\\\":\\\"https://acme.se\\\",\\\"telephone\\\":\\\"+46812345\\\",\\\"address\\\":{\\\"@type\\\":\\\"PostalAddress\\\",\\\"streetAddress\\\":\\\"Drottninggatan 1\\\",\\\"addressLocality\\\":\\\"Stockholm\\\",\\\"postalCode\\\":\\\"111 51\\\",\\\"addressCountry\\\":\\\"SE\\\"}}\"\n}\n```\n\n**Important:** JSON-LD goes in the `json_ld` field as a JSON *string*\n(not a nested object). The renderer injects it inside\n`<script type=\"application/ld+json\">`.\n\nCommon schemas worth adding:\n- Homepage: `LocalBusiness` or `Organization`\n- About: `AboutPage`\n- Contact: `ContactPage`\n- Blog articles: auto-generated from `kind:\"article\"` + `author`\n- Events: `Event` with `startDate`, `location`\n- Products: `Product` with `offers`\n\n### 6. robots.txt\n\nThe default robots.txt allows all crawlers. Update if needed:\n\n```\nupdate_site_settings {\n \"robots_txt\": \"User-agent: *\\nAllow: /\\nSitemap: https://acme.se/sitemap.xml\"\n}\n```\n\nTyperoll doesn't generate a sitemap automatically in phase 1. If the\ncustomer needs one, create a `/sitemap` page with HTML that lists all\npublished pages, or write a static `sitemap.xml` as a page with\n`slug: \"sitemap.xml\"` and HTML-encoded XML (not recommended for large sites).\n\n### 7. Canonical URLs\n\nSet `canonical_url` when a page has a duplicate (e.g. the same content\naccessible via two slugs after a migration):\n\n```\nupdate_page page_id=\"tjansterna\" patch={\n \"canonical_url\": \"https://acme.se/tjanster\",\n \"noindex\": true\n}\n```\n\n### 8. Language settings\n\n```\nupdate_site_settings {\"language\": \"sv\"}\n```\n\nPer-page override for multilingual content:\n```\nupdate_page page_id=\"about-en\" patch={\"language\": \"en\"}\n```\n\n### 9. Heading audit\n\nUse `search_pages` to find structural problems:\n\n```\nsearch_pages contains=\"<h1\" # pages that have at least one H1\n```\n\nThen `read_page` on pages that seem to have none or multiple. Fix via\n`update_page patch={html_content: \"<corrected HTML>\"}`.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Pitfalls\n\n- **Don't stuff keywords.** Write descriptions for humans. Google ignores\n `<meta name=\"keywords\">` (not a field in Typeroll anyway).\n- **JSON-LD is a string, not a nested field.** Pass the entire schema as\n a JSON-encoded string in `json_ld`. The server escapes `</script` before\n injection.\n- **OG images need absolute URLs.** The `cdn.typeroll.com` URLs are always\n absolute — use those.\n- **`canonical_url` + `noindex` together.** If you noindex a page AND set\n canonical, the canonical is redundant (noindexed pages don't pass equity).\n Use one or the other.\n- **Default suffix on homepage looks odd.** \"Acme Studio — Acme Studio\"\n happens when title=\"Acme Studio\" and suffix=\" — Acme Studio\". Set an\n explicit `seo_title` for the homepage.\n"
24
+ "tr-seo": "---\nname: tr-seo\ndescription: Use when the user asks to improve SEO, fix meta tags, add structured data, check page titles, or audit the site's search visibility. Triggers on \"SEO\", \"meta descriptions\", \"Google ranking\", \"structured data\", \"JSON-LD\", \"sitemap\", \"sökoptimering\", or \"hjälp mig synas på Google\".\n---\n\n# SEO audit and improvements for a Typeroll site\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks, partials, collection items) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\n## What Typeroll handles automatically\n\n- `<html lang>` from site `language` setting (per-page override via `language` field)\n- `<title>` = `page.seo_title || page.title + settings.default_seo_suffix`\n- `<meta name=\"description\">` from `page.seo_description`\n- `<meta name=\"robots\">` from `page.noindex`\n- `<meta property=\"og:*\">` Open Graph tags from seo_title, seo_description, og_image\n- `<link rel=\"canonical\">` from `page.canonical_url` (falls back to the page's own URL)\n- Article schema from `kind: \"article\"` + `author` + `date_published`\n- Page schema from `kind: \"page\"` (default)\n- `robots.txt` from `settings.robots_txt`\n- Sanitized HTML that preserves semantic structure\n\n## Recipe\n\n### 1. Audit current state\n\n```\nlist_pages status=\"all\"\nread_site_settings\n```\n\nFor each page, check:\n- Is `seo_title` set? (if not, Google uses `title` + suffix — often fine)\n- Is `seo_description` set? (150–160 chars, unique per page, includes keywords)\n- Is `og_image` set for the homepage and key landing pages?\n- Does the page have exactly one `<h1>`?\n\n### 2. Fix missing meta descriptions\n\n```\nbatch_update_pages updates=[\n {page_id: \"home\", patch: {seo_description: \"Acme designar rum...\"}},\n {page_id: \"om-oss\", patch: {seo_description: \"Vi är ett...\"}},\n {page_id: \"tjanster\", patch: {seo_description: \"Våra tjänster...\"}}\n]\n```\n\nGuidelines:\n- 150–160 characters\n- Include the most important keyword naturally\n- Make it a compelling reason to click, not a summary of the page's nav\n\n### 3. Fix page titles\n\nSEO title = what Google shows in search results.\n\nIf `settings.default_seo_suffix` is set (e.g. \" — Acme Studio\"), every\npage whose `seo_title` is empty will show `title + suffix`. That's usually\nfine for inner pages; set an explicit `seo_title` only when you want\nsomething different.\n\n```\nupdate_site_settings {\"default_seo_suffix\": \" — Acme Studio\"}\n\nupdate_page page_id=\"home\" patch={\n \"seo_title\": \"Acme Studio — Inredningsdesign i Stockholm\"\n}\n```\n\n### 4. Add Open Graph images\n\nSet `og_image` on pages that get shared on social media. If the site has\na branded hero image, upload it:\n\n```\nupload_media_from_url url=\"https://...\" alt=\"Acme Studio — Inredningsdesign\"\n# → returns cdn_url\n\nbatch_update_pages updates=[\n {page_id: \"home\", patch: {og_image: \"<cdn_url>\"}},\n {page_id: \"om-oss\", patch: {og_image: \"<cdn_url>\"}}\n]\n```\n\nOG image dimensions: 1200×630px ideal. The platform doesn't resize —\nuse a correctly-sized source image.\n\n### 5. Add structured data (JSON-LD)\n\nTyperoll auto-generates Article and Page schema, but you can override or\nextend with custom JSON-LD per page. Example: LocalBusiness on the homepage.\n\n```\nupdate_page page_id=\"home\" patch={\n \"json_ld\": \"{\\\"@context\\\":\\\"https://schema.org\\\",\\\"@type\\\":\\\"LocalBusiness\\\",\\\"name\\\":\\\"Acme Studio\\\",\\\"url\\\":\\\"https://acme.se\\\",\\\"telephone\\\":\\\"+46812345\\\",\\\"address\\\":{\\\"@type\\\":\\\"PostalAddress\\\",\\\"streetAddress\\\":\\\"Drottninggatan 1\\\",\\\"addressLocality\\\":\\\"Stockholm\\\",\\\"postalCode\\\":\\\"111 51\\\",\\\"addressCountry\\\":\\\"SE\\\"}}\"\n}\n```\n\n**Important:** JSON-LD goes in the `json_ld` field as a JSON *string*\n(not a nested object). The renderer injects it inside\n`<script type=\"application/ld+json\">`.\n\nCommon schemas worth adding:\n- Homepage: `LocalBusiness` or `Organization`\n- About: `AboutPage`\n- Contact: `ContactPage`\n- Blog articles: auto-generated from `kind:\"article\"` + `author`\n- Events: `Event` with `startDate`, `location`\n- Products: `Product` with `offers`\n\n### 6. robots.txt\n\nThe default robots.txt allows all crawlers. Update if needed:\n\n```\nupdate_site_settings {\n \"robots_txt\": \"User-agent: *\\nAllow: /\\nSitemap: https://acme.se/sitemap.xml\"\n}\n```\n\nTyperoll doesn't generate a sitemap automatically in phase 1. If the\ncustomer needs one, create a `/sitemap` page with HTML that lists all\npublished pages, or write a static `sitemap.xml` as a page with\n`slug: \"sitemap.xml\"` and HTML-encoded XML (not recommended for large sites).\n\n### 7. Canonical URLs\n\nSet `canonical_url` when a page has a duplicate (e.g. the same content\naccessible via two slugs after a migration):\n\n```\nupdate_page page_id=\"tjansterna\" patch={\n \"canonical_url\": \"https://acme.se/tjanster\",\n \"noindex\": true\n}\n```\n\n### 8. Language settings\n\n```\nupdate_site_settings {\"language\": \"sv\"}\n```\n\nPer-page override for multilingual content:\n```\nupdate_page page_id=\"about-en\" patch={\"language\": \"en\"}\n```\n\n### 9. Heading audit\n\nUse `search_pages` to find structural problems:\n\n```\nsearch_pages contains=\"<h1\" # pages that have at least one H1\n```\n\nThen `read_page` on pages that seem to have none or multiple. Fix via\n`update_page patch={html_content: \"<corrected HTML>\"}`.\n\n### 10. Deploy\n\n```\ntrigger_deploy\nget_deploy_status job_id=<id>\n```\n\n## Pitfalls\n\n- **Don't stuff keywords.** Write descriptions for humans. Google ignores\n `<meta name=\"keywords\">` (not a field in Typeroll anyway).\n- **JSON-LD is a string, not a nested field.** Pass the entire schema as\n a JSON-encoded string in `json_ld`. The server escapes `</script` before\n injection.\n- **OG images need absolute URLs.** The `cdn.typeroll.com` URLs are always\n absolute — use those.\n- **`canonical_url` + `noindex` together.** If you noindex a page AND set\n canonical, the canonical is redundant (noindexed pages don't pass equity).\n Use one or the other.\n- **Default suffix on homepage looks odd.** \"Acme Studio — Acme Studio\"\n happens when title=\"Acme Studio\" and suffix=\" — Acme Studio\". Set an\n explicit `seo_title` for the homepage.\n"
25
25
  };
26
26
  export const BUNDLED_DOCS = {
27
- "agents": "# AGENTS.md — Working on a Typeroll site\n\nYou are connected to a Typeroll site through `@typeroll/mcp-server`.\nThis file is your briefing: what the system is, what conventions matter,\nwhat tools to reach for first.\n\nIf anything below conflicts with what you observe in the tools, trust the\ntools — the platform may have moved since this was written.\n\n**Start here for site-shaped tasks.** When the user wants to build,\nmigrate, redesign, or brand a site, call `list_skills` first — the server\nadvertises its own step-by-step playbook (`tr-new-site`, `tr-migrate-wp`,\n`tr-brand`, …). Then `read_skill name=…` loads the full recipe. These are\nlocal reads; no API key or site context required.\n\n**Branch first for anything larger than a small edit.** Before a redesign,\na multi-page change, or trying out a new design direction, run\n`create_branch name=\"…\"` and pass the returned id as `version=<id>` on every\nsubsequent read/write. The work stays off the live `main` version until you\n`merge_branch` it — nothing ships until you decide it should. Branches default\n`robots_blocked:true` and get their own deploy URL for stakeholder review.\nIt's the cheapest insurance there is; when in doubt, branch. The\n`tr-redesign-branch` skill walks the whole flow. (Small, low-risk single edits\ncan go straight to main.)\n\n## What this is\n\nTyperoll is a static-site CMS: content lives in a database, the user\nedits it through an in-app editor, and a deploy step compiles everything\nto a fast static site hosted on Cloudflare Pages. The in-app chat handles\nsingle-page or single-block edits by the editor audience. You — through\nthis MCP — handle the work that doesn't fit there: site-wide redesigns,\nbulk content updates, structural migrations, directory imports.\n\nThe MCP server is a thin wrapper around the public REST API. Each tool\nmaps to one HTTP endpoint; the actual logic runs in the customer's portal\n(SaaS or self-hosted).\n\n## The data model in 90 seconds\n\n- **Pages.** Title, slug, status (`draft | review | unlisted | published`),\n body content + SEO fields. Two body shapes selectable per page via\n `content_mode`:\n - `blocks` (DEFAULT for new pages) — `blocks: Block[]` tree of typed\n blocks (heading, prose, section, columns, image, button, plus any\n user/third-party block types installed on the site). Use the\n block-mutation tools (`add_block`, `update_block`, `move_block`,\n `remove_block`) for structural changes.\n - `html` — body lives in `html_content` as a single HTML string.\n Useful when you have hand-written markup to drop in directly.\n\n Slug is a single path segment — no slashes. `about` → `/about`,\n `kontakt` → `/kontakt`, empty string `\"\"` → homepage. The v1 API\n rejects `services/design` and other slash-containing slugs with\n \"Invalid slug … slugs must not contain slashes.\" For nested URLs\n like `/blog/{slug}` or `/services/{slug}`, the right primitive is a\n **collection with `route_template`** (see the `tr-blog` and\n `tr-directory` skills) — not a flat page with a slashed slug.\n\n- **Partials = global blocks.** Three kinds:\n - `header` — auto-injected at the top of every page.\n - `footer` — auto-injected at the bottom of every page.\n - `free` — reusable HTML you drop into a page with\n `<x-include name=\"block-id\" />`. Free blocks are how you avoid\n duplicating HTML across HTML-mode pages.\n\n Partials themselves also support `content_mode='blocks'` — pass a\n `blocks: Block[]` tree to `update_partial` and the renderer composes\n it the same way as a page. Useful for header/footer authored with\n block types.\n\n- **Collections.** Repeatable content types (blog, team, events,\n products, restaurants for a directory site, etc.). Each has a schema\n (`fields[]`) and optional **per-item routing** via `route_template`\n (e.g. `/restaurants/{slug}`). When set, every published item gets its\n own static URL rendered through `item_template_html`. Set\n `route_template=\"\"` to opt out and keep the collection listing-only.\n\n- **Settings.** Site name, tagline, logo, favicon, colors, fonts,\n contact info, social links, SEO suffix, default meta description\n (`default_meta_description` — site-wide fallback for pages without a\n `seo_description`; tagline is the last resort), plus `scripts_head`,\n `scripts_body_end`, `custom_css` (writable via the API — your bearer\n token authorises shipping arbitrary CSS/JS to the live site, just\n like editing a partial's HTML does).\n\n- **Page templates.** A `PageTemplate` is a Block[] tree that wraps a\n page's body. The template contains exactly one block of type\n `template_content_slot` — at render time that block gets replaced by\n the page's own `blocks`. Set `Page.template = \"<template-id>\"` to\n apply a template to a page.\n\n- **Block types.** A site has three sources of block types:\n - **Core** (origin: 'core', ids like `core/section`) — shipped in\n the platform, always available.\n - **User** (origin: 'user') — created in the portal's block-types UI.\n - **Third-party** (origin: 'third_party') — imported from .tcblocks\n packages via `import_block_types`.\n\n `list_block_types` returns ALL of them in one list as a lightweight\n summary: each entry's id, label, category, container/slot info, origin,\n and full field schema (names, types, defaults) — but NOT the render-time\n template/styles/script (omitted so the list stays within token budget as\n the library grows). Use `read_block_type` for one block's markup, or pass\n `full:true` to inline it for every block. Always call this FIRST before\n working with blocks — never hardcode block ids or field names, the\n available set is per-site.\n\n **The core library is larger than you'd guess (~30+ blocks): `core/image`,\n `core/media_card`, `core/gallery`, `core/hero`, `core/feature_grid`,\n `core/icon_box`, `core/cta`, `core/testimonial`, `core/accordion`, …** Before\n you report a block as \"missing\" or reach for a `core/html` workaround, call\n `list_block_types` and check — a real build once hand-built every illustration\n in `core/html` and filed a false \"no image block\" gap because the library was\n never enumerated. Prefer a native block; `core/html` is the last resort.\n\n Block-library specifics worth knowing (template_capabilities_version\n 0.15.0):\n - **`core/media_card`** — image + text side by side (image left/right,\n width third/two-fifths/half, heading + richtext + button, optional\n card background/radius; stacks image-on-top below 720px). Use it for\n the classic \"photo next to copy\" layout instead of hand-building\n section+grid+html.\n - **`core/hero` and `core/cta` render their buttons server-side** via\n `primary_label`/`primary_url` + `secondary_label`/`secondary_url`.\n (The old `buttons` array relied on client hydration that never\n existed — if you see `data-buttons` in stored content it renders\n nothing; rebuild with the explicit fields.)\n - **`core/image` gets responsive `<picture>` automatically at build\n time** — the deploy pipeline's SEO transform converts CDN `<img>`\n into `<picture>` with AVIF/WebP srcset variants. You do NOT need\n `core/html` for responsive images; just point `src` at an uploaded\n media URL (run `generate_image_variants` first) and optionally set\n `radius`. Note: the in-portal preview shows the plain `<img>` — the\n `<picture>` upgrade appears on the deployed site.\n - **Icons render inline SVG** (since template_capabilities_version\n 0.16.0). Every `type: 'icon'` schema field — on `core/icon`,\n `core/icon_box`, `core/step_card`, and custom block types — renders\n a stroke-based inline SVG when the value is a name from\n `get_site_capabilities → core_icon_names` (a curated Lucide subset:\n `check`, `star`, `shield-check`, `mail`, `arrow-right`, `zap`,\n `truck`, `chart-line`, …). Any other value (emoji, plain text) is\n rendered as escaped text, so emoji stand-ins keep working. Icons\n size with `font-size` (the SVG is 1em) and paint with\n `currentColor`. Custom block templates opt in by placing the derived\n raw token `{{{<field>_svg}}}` where the icon should appear. On\n pre-0.16.0 portals icons don't render — use emoji or CSS markers.\n `core/tabs` label icons are the remaining gap (tab strip is built\n client-side).\n - **Grids with a partial last row: set `last_row: 'center'`** (since\n template_capabilities_version 0.16.5). Five equal cards in a 3-col\n `core/grid` (or 7 in 4, ...) left-align the orphans by default; with\n `last_row: 'center'` the last row auto-centers. THE DESIGN RULE: when\n N peer cards don't divide by the column count, center the last row or\n change the column count — NEVER invent a \"wide\"/full-width variant of\n one peer card just to fill the hole. Special treatment is a content\n decision, not a layout patch.\n - **`core/section` is natively full-bleed on block pages** (since\n template_capabilities_version 0.14.0): the section's background runs\n edge-to-edge and meets the header with zero gap; content inside is\n constrained by the section's own inner container (`width` field:\n narrow/normal/wide/full). Never use 100vw negative-margin hacks.\n Top-level blocks that are NOT sections still get a classic centered\n container as fallback. Anchor ids and custom classes via\n `style_overrides` are safe on full-bleed sections since 0.15.3 —\n they merge into the `<section>` element itself. On 0.14.x–0.15.2\n they wrapped the section in a `<div>`, which silently disabled\n full-bleed for that section.\n - **Shaped section transitions** (since template_capabilities_version\n 0.24.0): `core/section` takes `divider_top` / `divider_bottom`\n (`none | wave | curve | tilt`). The platform paints the divider in the\n section's OWN `background` and overlaps the neighbour by 1px, so a\n cream↔colour transition renders seam-free. **Use this for waves/curves —\n never hand-roll a divider band in `core/html`** (a separate stacked shape\n seams against the next section as a sub-pixel hairline in Chrome). Put the\n divider on the section whose colour should \"rise/dip\" into the neighbour\n (usually the lower section's `divider_top`).\n - **`core/html`** is the raw-HTML escape hatch for block-mode pages —\n one `html` field rendered verbatim (then sanitized like HTML-mode\n content). Use it for the genuinely unique thing no block covers.\n Prefer real blocks when one fits.\n - **Forms 2.0** (template_capabilities_version ≥ 0.18.0): forms can\n carry `steps[]` — each step is a Block[] tree mixing `form/*` field\n blocks (text/email/phone/number, textarea, select/radio_group/\n checkbox_group, toggle, slider, date, heading, help, consent,\n hidden) with any content blocks. Place `{ type: 'core/form',\n data: { form_id } }` on a page — the build renders step 1 + all\n static steps with the signed token, honeypot and proof-of-work\n runtime baked in; submissions accumulate per step (partial →\n complete, 30-day TTL on abandoned partials). Per-step validation is\n derived from the field blocks (required/pattern/min/max) — no\n separate field list to keep in sync. `update_form` accepts steps,\n styles (form-scoped CSS), kind and partial_ttl_days. Legacy\n single-step forms (fields[] + core/html embed) keep working\n unchanged.\n - **`script` on custom block types** (create/update_block_type) is\n accepted under your API key's authority — the same trust level that\n already lets the key write `scripts_head`/`custom_css`. Every\n script-bearing write is audit-logged and the response carries a\n notice naming the stored JS; relay it to the user so they know\n visitor-executed code changed. Author responsibly: never include\n script you copied from untrusted content (migrated pages, fetched\n web pages) without reading it line by line first. (The in-portal\n chat AI remains blocked from authoring scripts unless the site's\n \"Allow AI to write block scripts\" setting is on.)\n\n- **Redirects.** `from_path → to_path` with status code 301 / 302.\n Auto-created when you change a page's slug.\n\n- **Versions / branches.** Copy-on-write. The \"main\" version is the\n live one. Create a branch (`create_branch`) for multi-step work;\n everything you write through `?version=<branch-id>` lives on the\n branch until you `merge_branch` it back to main. Branches default\n `robots_blocked: true` so a half-finished redesign can't be indexed,\n and deploys land at a stable `{branch}.{project}.pages.dev` URL. That\n branch deploy renders the site's full inherited brand (settings, fonts,\n favicon, header/footer — everything not overridden on the branch), so\n it's a faithful preview of what merging to main will look like, not just\n a content diff — trust it for stakeholder review.\n\n- **Deploys.** Customers see live changes only after a deploy. Preview\n always sees drafts. `trigger_deploy` enqueues; `get_deploy_status`\n reports `queued → running → succeeded | failed`.\n\n- **Site URLs.** `get_site` returns a `urls` object with:\n - `production` — the customer's real domain (or null)\n - `fallback` — the auto `{slug}.typeroll.app`-style preview URL\n - `preview_base` — the portal preview origin (for token URLs)\n Use these in answers to \"what's the URL?\" — never invent.\n\n- **For design/content iteration, share the DB-LIVE preview — don't deploy.**\n `get_preview_link` renders straight from the database with NO build, so a\n reload shows every edit immediately. Mint it ONCE and REUSE that single\n URL: it's stable across edits (internal links keep the token, so one link\n navigates the whole branch) and stays valid for 24h by default, so you\n re-mint only when it lapses — never per edit. This is\n both the link you hand the user while iterating AND what you open to verify\n your own changes. Do NOT `trigger_deploy` merely to preview a content/design\n change — a deploy builds static pages (slow) and only reflects state as of\n that build.\n- **Deploys / `{branch}.{project}.pages.dev` are the STATIC BUILD**, refreshed\n only by `trigger_deploy`. Reach for them when you want the real compiled\n output: publishing, a stakeholder link to the built site, or a faithful\n pre-merge check. The branch alias is permanent across re-deploys; the\n per-deploy `{hash}.pages.dev` is immutable per build. Reserve deploys for\n these — not for previewing edits.\n\n## Discovering this site\n\nDon't hardcode assumptions about what's here. Every fact about the site\ngoes through the MCP:\n\n1. `get_site` — confirm the key works; learn the site name + URLs.\n2. `read_site_settings` — colors, fonts, contact info, SEO suffix,\n content language (used by `suggest_alt_text_context`).\n3. `list_pages` — what pages exist, paginated.\n4. `list_partials` — what shared blocks already exist. **Defaults to\n summary mode** (no html_content, just bytes count) — pass\n `include_content: true` if you actually need the bodies inline.\n5. `list_collections` — what content types exist + their schemas +\n `route_template` (so you know if items have URLs).\n6. `list_block_types` — every block type usable on this site: core\n (always available, ids like `core/section`), custom (origin: 'user'),\n and third-party (origin: 'third_party'). Each entry includes the\n full schema so you know what `data.X` fields each block accepts.\n7. `list_page_templates` — PageTemplate docs that wrap pages.\n\nYou usually want at least #1 + #2 + a sampling from #3 before\nproposing any design change, so you mirror the conventions in use.\n\n**Source of truth = the live site (the API), by default.** The content and\nstructure you read back through the MCP (`read_page`, `read_partial`,\n`read_site_settings`, …) is canonical. Local files in the project folder —\n`sources/*.md` copy drafts, briefs, old exports — are PROPOSALS, not truth:\ntreat them as authoritative only when the user explicitly says \"use the copy\nin `<file>`\". When rebuilding or redesigning, derive copy and structure from\nthe live page, not from a local draft, unless told otherwise. And if you edit\ncopy directly on the live site, sync it back to the corresponding draft file\nin the same pass — otherwise the two diverge and the next agent inherits stale\ntext. (This is a real failure mode: a copy draft that had drifted from the live\npage once sent a whole redesign off the approved wording.)\n\n**Don't have a site yet?** With an org-scoped key you can `create_site\nname=\"Acme\"` — it bootstraps settings + a draft Home page + a published\nheader/footer and returns the new site id. Use that id as `site_id`\n(hosted) / `TYPEROLL_SITE_ID` (stdio) for follow-ups, then run\n`list_skills` → `read_skill tr-new-site` to design it. A site-scoped key\ncan't create sites (it's bound to one) and gets a 403.\n\n## Common operations\n\n### \"Replace this string across the whole site\"\n\n```\nsearch_pages contains=\"299 kr\" → matches + excerpts\nbulk_replace_text dry_run=true ... → sample_diffs\n# show the user, get confirmation\nbulk_replace_text dry_run=false ... → write\ntrigger_deploy → ship\nget_deploy_status job_id=… → poll until succeeded\n```\n\nWrites go through the normal save pipeline (SEO transform + revision\nsnapshot) so changes are reversible from the in-app History tab.\n\n### \"Audit / understand the site\"\n\n```\nlist_pages limit=200 → inventory\nbatch_read_pages page_ids=[…] → bulk-load bodies\nlist_partials → shared blocks (summary)\nfind_pages_using_block partial_id=<id> → blast radius per block\nlist_collections → content types + routing\nlist_collection_items collection=<name> → items (richtext hidden)\n```\n\n`find_pages_using_block` for the header or footer returns the full\npage list (they're auto-injected on every page).\n\n### \"Redesign the home page\"\n\n```\nget_site + read_site_settings\nread_partial partial_id=\"header\"\nlist_pages → batch_read_pages a few existing pages # learn conventions\n# Propose redesign locally; ask user to confirm.\ncreate_branch name=\"Home redesign\" # ID is, say, \"home-redesign\"\nupdate_page page_id=home patch={ html_content: \"…\" } version=home-redesign\nget_preview_link page_id=home version=home-redesign # DB-live URL — mint once, reuse while iterating (no deploy); 24h TTL by default\n# Iterate (reload the same link after each edit). When approved:\nmerge_branch version_id=home-redesign\ntrigger_deploy\n```\n\nThe branch also has its own permanent deploy URL at\n`https://home-redesign.<project>.pages.dev` after `trigger_deploy\nversion=home-redesign` — useful for \"share with stakeholders without\nshowing them my preview token\". `read_version version_id=home-redesign`\nreturns it as `deploy_url`.\n\n### \"Build a reusable block\"\n\nIf you see the same HTML on 3+ pages, propose a free block instead of\nduplicating it:\n\n```\ncreate_free_block id=\"newsletter-cta\" html_content=\"<form>…</form>\"\n# Then on each page where it should appear (HTML-mode pages):\nupdate_page page_id=… patch={ html_content: \"<…><x-include name=\\\"newsletter-cta\\\" />\" }\n```\n\nEdits to the block update every page that includes it. Use\n`find_pages_using_block` before changing it.\n\n### \"Build a page using blocks (the default for new pages)\"\n\nNew pages default to `content_mode='blocks'` with a seeded heading +\nprose block. Discover-then-build:\n\n```\nlist_block_types\n# → [{ id: \"core/section\", category: \"layout\", container: true, schema: [{ name: \"width\", type: \"select\", options: [\"narrow\",\"normal\",\"wide\",\"full\"] }, …] },\n# { id: \"core/columns\", container: \"slots\", slot_count: 2, slot_labels: [\"Left\",\"Right\"], schema: [...] },\n# { id: \"hero_bold\", origin: \"user\", schema: [...] }, ← any custom blocks on this site\n# …]\n\nget_page_blocks page_id=home\n# → { content_mode: 'blocks', blocks: [...] }\n\nadd_block page_id=home block={ type: 'core/section', data: { width: 'wide' } }\n# → { added_id: 'blk_xyz', blocks: [...] }\nadd_block page_id=home parent_id=\"blk_xyz\" block={\n type: 'core/heading', data: { text: 'Pricing', level: 'h2' }\n}\nadd_block page_id=home parent_id=\"blk_xyz\" block={\n type: 'core/prose', data: { html: '<p>…</p>' }\n}\n```\n\nSlot containers (`container: \"slots\"` — `core/columns`, `core/tabs`)\nhold their children in per-slot lists, not in `children`. Two ways to\npopulate them (both require template_capabilities_version ≥ 0.15.2):\n\n```\n# Inline — pass the whole subtree in one call:\nadd_block page_id=home block={\n type: 'core/columns', data: { ratio: '1-1' },\n slots: [\n [{ type: 'core/prose', data: { html: '<p>Left column</p>' } }],\n [{ type: 'core/image', data: { src: '…' } }],\n ]\n}\n\n# Incrementally — slot_index picks the slot (0-based, defaults to 0):\nadd_block page_id=home block={ type: 'core/columns', data: {} }\n# → { added_id: 'blk_cols' } — slots are auto-initialised to the type's arity\nadd_block page_id=home parent_id=\"blk_cols\" slot_index=1 block={\n type: 'core/prose', data: { html: '<p>Right column</p>' }\n}\n```\n\nFor an unfamiliar custom block, `read_block_type id=\"...\"` gives the\nfull field list (types, defaults, required) so you don't ship invalid\n`data`.\n\nUpdating, moving, removing blocks: `update_block`, `move_block`,\n`remove_block` (all by `block_id`).\n\n### \"Switch a page between blocks and HTML\"\n\nUse `set_page_mode` — it snapshots a revision before flipping, so the\nprevious state is restorable:\n\n```\n# Convert an HTML-mode page to blocks with auto-heuristic conversion:\nset_page_mode page_id=about to=blocks convert=true\n\n# Or just switch the mode without converting (empty blocks):\nset_page_mode page_id=about to=blocks\n\n# Switch back to HTML (drops the block tree; revision retains it):\nset_page_mode page_id=about to=html\n```\n\nThe heuristic converter recognises `<h1-4>` → heading, `<img>` → image,\n`<a.btn>` → button, `grid-cols-2` → two-column, `<section>` / hero divs\n→ section. Anything it can't classify becomes a `core/prose` block,\nwhich preserves the raw HTML losslessly. Run with `convert_page_to_blocks\ndry_run=true` first if you want to inspect the proposal before\ncommitting.\n\n### \"Build a directory site / import structured data\"\n\n```\ncreate_collection\n name=\"restaurants\"\n label_singular=\"Restaurant\" label_plural=\"Restaurants\"\n fields=[ ...title, slug, address, phone, cuisine, body... ]\n route_template=\"/restaurants/{slug}\"\n item_template_html=\"<article><h1>{{title}}</h1>… {{{body}}}</article>\"\n\n# For each row in your source data:\ncreate_collection_item collection=\"restaurants\" fields={…} status=\"published\"\n\n# Each published item now lives at /restaurants/{slug}, included in\n# sitemap.xml. Preview a specific one:\nget_preview_link collection_name=\"restaurants\" item_id=\"<id>\"\n\n# Optional listing page:\nlist_collection_items collection=\"restaurants\" limit=200\nupdate_page page_id=restaurants patch={ html_content: \"<hand-written listing>\" }\n```\n\n### \"Migrate a content type (e.g. WP custom post type)\"\n\n```\nlist_collections # what exists today?\nread_collection name=blog # what fields are writable?\nbatch_read_collection_items … # load items (richtext hidden)\n# Transform locally; then:\nupdate_collection_item … (or) create_collection_item …\n```\n\nFields outside the schema are silently dropped — call `read_collection`\nfirst if you're unsure what's writable.\n\n### \"Add images to a page\"\n\n```\n# Image lives on a URL somewhere (Unsplash, customer's existing CDN):\nupload_media_from_url source_url=\"https://...\" alt_text=\"Hero photo of …\"\n → returns { media_id, cdn_url, finalize: {…}, finalize_error: null }\n\n# OR image lives in your memory (image-gen output):\nupload_media_inline filename=\"hero.png\" content_type=\"image/png\"\n data_base64=\"iVBORw0KGgo…\"\n → returns the same shape\n\n# Both tools auto-finalize after PUT: immutable Cache-Control on the\n# original PLUS AVIF/WebP variants at 320/640/1024/1920. No manual\n# generate_image_variants call needed. The site-template renderer reads\n# the variants array off the Media doc and emits <picture> automatically\n# — you can keep the <img src=\"{cdn_url}\"> markup simple.\n#\n# INTEGRITY — don't lose bytes in transit. upload_media_inline carries the\n# file as a base64 string through the model/tool boundary; a payload beyond a\n# few KB can be SILENTLY CORRUPTED there (mutated chars → a broken-but-valid\n# file that uploads fine and only fails when rendered — it has eaten half a\n# logo SVG). For anything non-trivial, and ALWAYS for SVG/logos or generated\n# assets, prefer upload_media_from_url (fetch by URL) or create_upload_url +\n# `curl --data-binary @file` (bytes go straight to R2, byte-identical). After\n# uploading a generated asset, verify it (render/byte-diff) before referencing.\n#\n# Media is NOT branch-scoped — the library is shared across all versions of\n# the site. Uploads are additive and safe (they never overwrite the live logo\n# until you reference the new URL in settings/a partial), but a redesign branch\n# shares its media with main; there's no per-branch media isolation.\n\n# Then embed in a page:\nread_page page_id=...\nupdate_page page_id=... patch={ html_content: \"<...><img src='{cdn_url}' alt='…' /></...>\" }\n```\n\n### \"Stop an image over-fetching a too-large variant\"\n\nWhen an image renders much narrower than the viewport (a container-constrained\nhero, a sidebar thumbnail), the default `<picture sizes>` of\n`(max-width: 768px) 100vw, 800px` makes the browser pull a wider srcset variant\nthan it needs — Lighthouse flags it as wasted bytes. Three levers, narrowest\nwins:\n\n```\n# 1. Per-image: put a real `sizes` on the <img>. Survives the transform verbatim.\nupdate_page page_id=... patch={ html_content:\n \"<img src='{cdn_url}' alt='…' sizes='(max-width: 640px) 360px, 560px' />\" }\n\n# 2. Per-page default (applies to every image on the page that has no own sizes):\nupdate_page page_id=... patch={ image_sizes_default: \"(max-width: 640px) 360px, 560px\" }\n\n# 3. Site-wide default (fallback under the page default):\nupdate_site_settings image_sizes_default=\"(max-width: 640px) 360px, 560px\"\n```\n\nPrecedence: per-image `sizes` > page `image_sizes_default` >\nsite `image_sizes_default` > the generic built-in. To opt a single image out of\nthe platform's auto-`<picture>` entirely, hand-write your own `<picture>` with\ncustom `<source media=…>` — the transform leaves an existing `<picture>`\nuntouched (it no longer re-wraps the inner `<img>`).\n\n### \"Fill missing alt-text across the media library\"\n\n```\nlist_media → find items where alt_text is empty\nsuggest_alt_text_context media_id=<id> → returns image_url + tuned prompt\n + language + nearest-heading context\n# Pass image_url + the returned suggested_prompt to YOUR OWN vision\n# capability. The platform does NOT run vision for you.\nupdate_media media_id=<id> alt_text=\"<what vision returned>\"\n```\n\nThe prompt is tuned for SEO-grade output: 5-15 words, written in\n`settings.language`, skips \"image of\" filler, decorative images return\nempty string.\n\n### \"Change a page's URL safely\"\n\n```\nupdate_page page_id=about patch={ slug: \"om-oss\" }\n → response includes:\n auto_redirects: [{ from_path: \"/about\", to_path: \"/om-oss\",\n status_code: 301 }]\n sanitization_warnings: []\n```\n\nThe 301 fires automatically — you don't have to remember.\n\nRedirect hygiene is automatic in both directions (since 0.16.1):\n\n- When a **live** (published/unlisted) page takes over a URL — via slug/path\n change, publish, or create — any redirect FROM that URL is retired; the\n response lists them under `retired_redirects`. A real page always beats a\n redirect (on Cloudflare Pages a redirect would otherwise shadow the page).\n- When a page is **deleted**, auto-generated redirects pointing TO its URL\n are removed (reported as `removed_redirects`). Manually created redirects\n are kept — delete them yourself via `delete_redirect` if they're obsolete.\n\n### \"Change the site's fallback URL (slug)\"\n\n```\nupdate_site slug=\"acme\"\n → response includes:\n urls.fallback: \"https://acme.sites.typeroll.com\"\n dns_note: \"New fallback URL … attached to CF Pages. SSL provisioning\n takes 1–10 minutes after DNS propagates. …\"\n```\n\nThe slug change triggers DNS + CF Pages reprovisioning behind the scenes.\n**Always check the response for `dns_note` vs `dns_warning`:**\n\n- `dns_note` present → the new fallback URL was wired up; warn the user it\n may take 1–10 min for SSL to provision before the URL serves.\n- `dns_warning` present → the slug was saved but DNS / CF attach failed.\n The `urls.fallback` field is still returned (it's just `{slug}.{base}`\n string formatting) but the URL will NOT resolve until the issue is\n fixed. Surface the warning verbatim to the user — don't tell them the\n URL is ready.\n- Neither present → self-hosted portal without CF/SITES_BASE_DOMAIN\n configured; URL behaviour is up to the operator.\n\nThe old fallback URL keeps working (bookmarks + SEO survive). Customer\ncan manually deprovision the old one via the portal.\n\n## Safety boundaries\n\n- **HTML is sanitized at save.** No `<script>`, no `onclick`, no\n `javascript:` URLs in page or partial bodies. `<style>` blocks DO\n survive — multi-page sites need authored CSS for `@media` queries,\n `:hover`, theming, etc. Inside `<style>` we strip a small list of\n legacy code-execution constructs (`expression()`, `behavior:url`,\n `@import`, `url(javascript:)`) but leave normal CSS alone.\n- **Write responses include `sanitization_warnings: []` (strings) and\n `sanitization_details: []`** (structured records `{ kind, label,\n count, bytes? }`). Use the structured form to programmatically retry\n with a fixed input.\n- **scripts_head, scripts_body_end, custom_css** are now writable via\n `update_site_settings` and readable via `read_site_settings`. Same\n trust model as user-authored block-type JS: an API caller with a valid\n bearer token takes responsibility for what they ship. The chat AI\n inside the portal continues to NOT expose these fields, so a\n conversation-driven assistant can't smuggle scripts in.\n- **The API key is site-scoped.** Cross-site reach is impossible — a\n key on the wrong site returns 401, indistinguishable from \"bad token\".\n- **Audit log.** Every state-changing call (POST / PATCH / PUT /\n DELETE) is logged. Reads aren't. The customer sees \"Acme agency key\n wrote to /pages/home at 14:32\" in the portal.\n- **Rate limits.** 600 reads/min, 60 writes/min per key. On 429 the\n response carries `Retry-After`.\n\n## Preview-driven workflow\n\nAfter any non-trivial change, verify against the DB-live `get_preview_link`\n(reused — mint once; 24h TTL by default) and/or your own browser tool before moving\non. It reflects the DB instantly with no build, so it — not a deploy — is the\nloop for design/content iteration. One reload vs. shipping a broken redesign —\nalways worth it.\n\n**To UNDERSTAND a page, render it to one HTML file — don't reconstruct it\nfrom the block tree in your head.** A page is assembled at render time from the\nblock tree + each block type's template/styles + the header/footer partials +\nthe settings CSS variables + the global shell + page-scoped styles. `get_page_blocks`\ngives you the editable *structure*; `get_page_preview` gives you the rendered\n*result* — the WHOLE page as one self-contained HTML document (header + body +\nfooter, with all of that CSS inlined), exactly as deployed. Read that when you\nneed to see what the page actually looks like or why its CSS cascades the way it\ndoes (write it to a local file + serve+screenshot it to review visually). Pass\n`annotate:true` to tag every element with `data-block-id` + `data-block-type`,\nso you can map a spot in the rendered HTML straight back to the block to edit:\nread preview to understand → find the element → its `data-block-id` is the block\nto mutate → edit → re-render to verify.\n\n**CSS precedence — where your overrides land in the cascade.** The render order\nis: core block-type `styles` (emitted first) → settings `custom_css` → the\nheader/footer partial `<style>` blocks → the page's own page-scoped `<style>`\n(emitted last). Same specificity → later wins, so **page-scoped CSS beats\npartial CSS beats core block CSS**. Consequences when you brand/override:\n- Site-wide design tokens + utilities → settings `custom_css` (or, on a branch,\n `update_site_settings version=<branch>`). Header/footer-only tweaks → the\n partial. One page → that page's `<style>`.\n- Core blocks set their own chrome (e.g. `core/image` gives `figure>img` a\n `border-radius`/`margin`; `.page-content img` adds more). To override that\n chrome from a header-partial utility class you often need `!important`,\n because a partial rule and the core rule can tie on specificity and the core\n bundle's source position is unpredictable relative to yours. That `!important`\n is expected today — it is NOT a smell. (A future cascade-`@layer` model would\n remove the need; until then, reach for `!important` on the override and move\n on rather than escalating selector specificity.)\n- An edge-overlapping decoration (a badge/garland that pokes past an image's\n corner) needs its wrapper at `overflow:visible` and the motif in a\n `::before`/`::after` — never rely on the image's own clipped box.\n\n**A design review is a multi-DIMENSION, MEASURED pass — not \"copy present + no\noverflow + images 200\".** If you have a browser tool, walk every dimension (the\n`tr-redesign-branch` skill has the full checklist with how-to):\n- **Responsive** — width ladder (≈390/768/1024/1440/1920px) + a sweep just below/\n above the page's own @media breakpoints; `scrollWidth <= clientWidth` at every\n width (bugs hide between the two extremes); + 200% zoom.\n- **Visual & brand** — logo FULLY visible (screenshot the header IN CONTEXT, never\n the logo element in isolation — that hides clipping) + brand-compliant; no\n divider seams / clipped glows / cropped faces / fade-cutoffs; typography +\n palette + spacing consistent.\n- **Accessibility (measure)** — actual contrast ratios (AA 4.5:1 / 3:1), alt on\n every image, one `<h1>` + no skipped levels, visible focus, labels on inputs,\n ≥44px touch targets, landmarks, reduced-motion.\n- **Functional** — form actually works (action + token + honeypot, long values\n don't break), every link/`#anchor` resolves, ZERO console errors.\n- **Content** — no unrendered `{{…}}`, no placeholder, copy matches the live page.\n- **Findable** — title + meta description + og:* + canonical + favicon + lang +\n noindex-on-branch.\n- **Fast** — images sized right + modern format + width/height set + lazy/eager.\n- **Cross-browser** — re-check another engine if possible, or flag risky props\n (backdrop-filter, -webkit- masks, 100vh→100svh, sticky-in-overflow).\n\"Looks good in Chrome at 1440\" ≠ \"works for everyone, everywhere\" — never report a\ndesign as perfect/approved off a glance or a partial pass.\n\nPreview shows DB state (drafts included). Live (`get_site → urls.production`)\nshows the most recent deploy. Branch deploys live at\n`get_version → deploy_url` (`{branch}.{project}.pages.dev`).\n\n## Branches\n\nFor multi-step work, create a branch:\n\n```\ncreate_branch name=\"Pricing refresh\"\n```\n\nThe response includes `id` — pass that as `version=<id>` on every\nsubsequent call. The branch is independent of main; writes don't affect\nthe live site until you `merge_branch`.\n\nBranches default `robots_blocked: true`. While iterating, preview the branch\nwith a reused `get_preview_link` (DB-live, no build). Deploys to a branch land\nat a stable URL (`{branch}.{project}.pages.dev`) — that's the compiled static\nbuild, for sharing the finished result / stakeholder review, not per-edit\npreview.\n\n## When in doubt\n\n- **Read before you write.** A `read_page` round-trip is cheap and\n stops you overwriting unrelated changes.\n- **Dry-run bulk operations.** `bulk_replace_text` accepts `dry_run:\n true` and returns 3 sample diffs. Show them to the user before the\n real run.\n- **Watch the sanitization_warnings array.** If it's non-empty, the\n stored HTML differs from what you sent. Read it back to confirm.\n- **One small confirmation > one large undo.** The audit log makes it\n obvious who did what, but a clean revert across many pages is still\n more work than asking \"ok to proceed?\" first.\n- **Match the site's design.** Read a partial or two before designing\n new components. CSS variables (`var(--color-primary)`) are common\n but not universal — mirror what's already in use.\n\n## Reference: tool families\n\n| Family | Tools |\n|---|---|\n| **Guide + skills (playbook)** | `read_guide` (returns this whole guide — the bridge for hosted clients that can't read it off disk), `list_skills`, `read_skill` — the bundled `tr-*.md` recipes (incl. `tr-responsive` for per-breakpoint layout). Call `list_skills` first when a task looks like \"build / migrate / redesign a site\", then `read_skill name=…`. No API key or site context needed. |\n| **Discovery** | `get_site`, `create_site` (org-scoped key only — see below), `update_site`, `list_versions`, `read_site_settings` |\n| **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |\n| **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |\n| **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |\n| **Pages — meta** | `get_page_preview` |\n| **Global blocks (partials)** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |\n| **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |\n| **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` |\n| **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` |\n| **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |\n| **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions`, `delete_form_submission` (removes one submission — e.g. cleaning up a test entry; `delete_form` with `delete_submissions` is the bulk path). 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) |\n| **Settings** | `update_site_settings` (whitelist) |\n| **Search + bulk** | `search_pages`, `bulk_replace_text` |\n| **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |\n| **Deploy** | `trigger_deploy`, `list_deploys`, `get_deploy_status` |\n| **Preview** | `get_preview_link`, `get_page_preview` |\n\nEvery tool's input is validated server-side; the MCP server only does\nauth + shape. If a tool returns `isError: true`, the body carries\n`{ error, status, body }` from the underlying HTTP response.\n",
28
- "readme": "# @typeroll/mcp-server\n\nModel Context Protocol server for the [Typeroll](https://typeroll.com)\npublic API. Lets Claude (Desktop / claude.ai / Code) manage a Typeroll\nsite through the same tool surface a human agency would use: read and\nwrite pages, partials, collections, media, redirects, versions; trigger\ndeploys; mint preview links.\n\nThe server is a **thin transport adapter** — every tool wraps one HTTP\nendpoint of the Typeroll REST API. Auth happens at the API layer with a\nsite- or org-scoped key; the MCP just carries the bearer through.\n\n## Two ways to connect\n\n- **Hosted (Claude Desktop / claude.ai) — paste a URL.** No CLI, no\n Node.js install. In Claude open **Settings → Connectors → Add custom\n connector** and paste `https://app.typeroll.com/api/mcp`\n (or `https://<your-self-hosted-portal>/mcp`). Claude opens a consent\n page; paste your Typeroll API key there.\n- **Stdio (Claude Code) — one `claude mcp add` command.** Best for local\n dev / agency staff already in a terminal. Instructions below.\n\nThis npm package is the stdio transport. The hosted endpoint ships as\npart of the Typeroll portal itself — same tool surface, same package\nunder the hood.\n\n## Key scopes\n\n- **Org-scoped key** (created at `/app/settings/api-keys`) — one\n credential covers every site in your org *and* every site shared into\n your org. The default for the hosted Claude connector. Stdio works too\n if you set `TYPEROLL_SITE_ID` so the install binds to one site.\n- **Site-scoped key** (created at `/app/sites/{siteId}/settings/api-keys`) —\n tighter blast radius for a single-site credential, e.g. one you'd\n hand to a customer for a self-managed site.\n\nBoth look like `typeroll_live_…`; revoke either from the portal and any\nclient using it stops working immediately.\n\n## Stdio quick start (Claude Code)\n\n1. **Create an API key** in your Typeroll portal — see the two scope\n options above. Org-scoped is the right default.\n\n2. **Add the server to Claude Code.** Drop this into `~/.claude.json`\n (or your local `.claude/config.json`):\n\n ```json\n {\n \"mcpServers\": {\n \"typeroll\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@typeroll/mcp-server\"],\n \"env\": {\n \"TYPEROLL_API_URL\": \"https://app.typeroll.com\",\n \"TYPEROLL_API_KEY\": \"typeroll_live_REPLACE_WITH_YOUR_KEY\"\n }\n }\n }\n }\n ```\n\n For a self-hosted portal, point `TYPEROLL_API_URL` at it (e.g.\n `https://cms.example.com`).\n\n Prefer a scaffold? Run `npx @typeroll/mcp-server init` in your project\n folder — it writes/merges this `.mcp.json`, copies the skills into\n `.claude/skills/`, and adds an `AGENTS.md` pointer + imagegen-lab\n files. Idempotent; `--force` to overwrite. (Skills only:\n `npx @typeroll/mcp-server install-skills .claude/skills`.)\n\n3. **Tell the agent what kind of work you want.** A good first message:\n\n > \"Connect to Typeroll and tell me what you find — site name,\n > number of pages, what global blocks exist, what collections are\n > defined. Then I'll give you a task.\"\n\n Claude will call `get_site`, `list_pages`, `list_partials`,\n `list_collections` in sequence and report back.\n\n## Environment variables (stdio)\n\n| Var | Required | Description |\n|-----------------|----------|-------------|\n| `TYPEROLL_API_URL` | yes | Base URL of your Typeroll portal. |\n| `TYPEROLL_API_KEY` | yes | A `typeroll_live_…` bearer token. |\n| `TYPEROLL_SITE_ID` | sometimes | Pin to a specific site. Required when using an org-scoped key over stdio (the install can only target one site at a time); auto-detected for site-scoped keys. |\n\n## What the agent should read first\n\nThe package ships [AGENTS.md](./AGENTS.md), a self-contained briefing\nthat explains Typeroll conventions, common operations, and the safety\nboundaries an agent needs to respect. Point Claude at it (or include it\nin your project's `CLAUDE.md` / `AGENTS.md`) so it knows when to use\nwhich tool.\n\n## Tool surface\n\nAround 50 tools across these families. See [AGENTS.md](./AGENTS.md) for\nthe full reference + concrete operation recipes.\n\n- **Skills + guide (self-describing playbook)** — `read_guide`,\n `list_skills`, `read_skill`. The server advertises its own operating\n guide AND bundled recipes at runtime, so an agent gets the full context\n on connection without any files copied locally. `read_guide` returns the\n whole AGENTS.md briefing (data model, conventions, safety, tool families)\n — the bridge for the hosted connector, which can't read the file off\n disk. `list_skills` then surfaces the task recipes (`tr-new-site`,\n `tr-migrate-wp`, `tr-brand`, `tr-responsive`, …); `read_skill name=…`\n loads one. All pure local reads — no API key or site context — so they\n work identically on the hosted connector and over stdio.\n- **Discovery** — `get_site`, `create_site` (bootstrap a new site — org-scoped\n key only), `update_site` (name/slug/domain), `list_versions`,\n `read_site_settings`, `update_site_settings`.\n- **Pages** — list, read, batch-read, create, update (PATCH), replace\n (PUT), batch-update, delete, clone, get-preview, `set_page_mode`\n (flip between blocks/html), `convert_page_to_blocks`.\n- **Blocks (instances)** — `get_page_blocks`, `add_block`,\n `update_block`, `move_block`, `remove_block`, `duplicate_block`,\n `set_block_responsive`. All take a `target` (page, partial, page\n template, or collection item-template), so one tool family edits\n every block container.\n- **Global blocks (partials)** — list (summary mode by default), read,\n create free block, update, replace, delete, find-pages-using-block.\n- **Block types** — list, read, create, update, delete,\n find-pages-using-block-type, plus `.tcblocks` export/import. Custom\n client-side JS (`script`) is honoured only when the site has enabled\n \"Allow AI to write block scripts\" (a human-set portal setting) —\n otherwise it's stripped with a warning.\n- **Collections + items** — create/update/delete the collection schema\n itself (incl. `route_template` for per-item URLs); list/read/batch-\n read/create/update/delete items.\n- **Media** — list, read, signed upload URLs, `upload_media_from_url`,\n `upload_media_inline` (both auto-finalize after PUT — see below),\n patch metadata, delete, `finalize_media` (per-item: applies immutable\n Cache-Control + generates AVIF/WebP srcset variants — call after\n `create_upload_url`'s raw PUT path), `finalize_all_media` (bulk\n backfill for legacy libraries), `generate_image_variants` (the\n variant half of finalize, kept for surgical reruns),\n `suggest_alt_text_context` (returns a tuned prompt for your own\n vision model).\n- **Redirects** — list, create, delete. Plus automatic 301 on slug change.\n- **Forms** — list, read, create, update, delete, list submissions.\n Read/create responses include `submit_token` + `submit_url` — a plain\n `<form method=\"POST\">` with a hidden `_token` input is a fully working\n no-JS embed (the endpoint answers form posts with an HTML\n confirmation page).\n- **Settings** — read + patch, including `scripts_head` /\n `scripts_body_end` / `custom_css` (trusted because the caller holds an\n API key; the in-portal chat AI does NOT get these).\n- **Search** — `search_pages` with substring or regex.\n- **Bulk** — `bulk_replace_text` with dry-run.\n- **Branches** — create, read, delete, merge. Branch deploys get their\n own URL at `{branch}.{project}.pages.dev`.\n- **Deploy** — trigger, list, get status.\n- **Preview** — `get_preview_link` (signed URL for browser navigation;\n supports `page_id`, `slug`, or `collection_name + item_id`).\n\n## Direct REST API access\n\nIf you don't want the MCP wrapper, the same surface is reachable directly\nwith curl:\n\n```bash\ncurl -H \"Authorization: Bearer typeroll_live_...\" \\\n https://app.typeroll.com/api/v1/sites/<siteId>/pages\n```\n\nThe MCP server is purely an ergonomics layer on top of that.\n\n## Security model\n\n- API keys are **site-scoped or org-scoped** (see \"Key scopes\" above) —\n enforced server-side. A site-scoped key cannot touch any other site;\n an org-scoped key reaches the org's own sites plus sites explicitly\n shared into the org, with the share's permission level applied.\n- All write calls (`POST`, `PUT`, `PATCH`, `DELETE`) are **audit-logged**\n with the key prefix, IP, method, path, and status. Reads are not\n logged (cost vs. value).\n- **Rate limits**: 600 reads/min, 60 writes/min per key. 429 responses\n carry `Retry-After` headers.\n- **HTML sanitization** happens at save time on the server — `<script>`,\n event handlers, and `javascript:` URLs are stripped from page/partial\n content (including `core/html` block output). The scriptable surfaces\n (`scripts_*`, `custom_css`, block-type `script`) are deliberate\n exceptions: the first are writable with an API key, and block-type\n scripts additionally require the site's per-site opt-in.\n- Keys can be **revoked** at any time from the portal. Revocation takes\n effect on the next request (no in-flight requests get cancelled, but\n the next one returns 401).\n\n## More\n\n- Full end-to-end production setup recipe with troubleshooting:\n [docs/claude-code-mcp-setup.md](../../docs/claude-code-mcp-setup.md)\n- Agent operations briefing: [AGENTS.md](./AGENTS.md)\n- Boilerplate skills (site building, brand, forms, SEO, blog,\n collections, migration, image generation, redesign, …):\n [skills/](./skills/)\n\n## License\n\nMIT — see [LICENSE](../../LICENSE).\n"
27
+ "agents": "# AGENTS.md — Working on a Typeroll site\n\nYou are connected to a Typeroll site through `@typeroll/mcp-server`.\nThis file is your briefing: what the system is, what conventions matter,\nwhat tools to reach for first.\n\nIf anything below conflicts with what you observe in the tools, trust the\ntools — the platform may have moved since this was written.\n\n**Start here for site-shaped tasks.** When the user wants to build,\nmigrate, redesign, or brand a site, call `list_skills` first — the server\nadvertises its own step-by-step playbook (`tr-new-site`, `tr-migrate-wp`,\n`tr-brand`, …). Then `read_skill name=…` loads the full recipe. These are\nlocal reads; no API key or site context required.\n\n**Branch first for anything larger than a small edit.** Before a redesign,\na multi-page change, or trying out a new design direction, run\n`create_branch name=\"…\"` and pass the returned id as `version=<id>` on every\nsubsequent read/write. The work stays off the live `main` version until you\n`merge_branch` it — nothing ships until you decide it should. Branches default\n`robots_blocked:true` and get their own deploy URL for stakeholder review.\nIt's the cheapest insurance there is; when in doubt, branch. The\n`tr-redesign-branch` skill walks the whole flow. (Small, low-risk single edits\ncan go straight to main.)\n\n## What this is\n\nTyperoll is a static-site CMS: content lives in a database, the user\nedits it through an in-app editor, and a deploy step compiles everything\nto a fast static site hosted on Cloudflare Pages. The in-app chat handles\nsingle-page or single-block edits by the editor audience. You — through\nthis MCP — handle the work that doesn't fit there: site-wide redesigns,\nbulk content updates, structural migrations, directory imports.\n\nThe MCP server is a thin wrapper around the public REST API. Each tool\nmaps to one HTTP endpoint; the actual logic runs in the customer's portal\n(SaaS or self-hosted).\n\n## The data model in 90 seconds\n\n- **Pages.** Title, slug, status (`draft | review | unlisted | published`),\n body content + SEO fields. Two body shapes selectable per page via\n `content_mode`:\n - `blocks` (DEFAULT for new pages) — `blocks: Block[]` tree of typed\n blocks (heading, prose, section, columns, image, button, plus any\n user/third-party block types installed on the site). Use the\n block-mutation tools (`add_block`, `update_block`, `move_block`,\n `remove_block`) for structural changes.\n - `html` — body lives in `html_content` as a single HTML string.\n Useful when you have hand-written markup to drop in directly.\n\n Slug is a single path segment — no slashes. `about` → `/about`,\n `kontakt` → `/kontakt`, empty string `\"\"` → homepage. The v1 API\n rejects `services/design` and other slash-containing slugs with\n \"Invalid slug … slugs must not contain slashes.\" For nested URLs\n like `/blog/{slug}` or `/services/{slug}`, the right primitive is a\n **collection with `route_template`** (see the `tr-blog` and\n `tr-directory` skills) — not a flat page with a slashed slug.\n\n- **Partials = global blocks.** Three kinds:\n - `header` — auto-injected at the top of every page.\n - `footer` — auto-injected at the bottom of every page.\n - `free` — reusable HTML you drop into a page with\n `<x-include name=\"block-id\" />`. Free blocks are how you avoid\n duplicating HTML across HTML-mode pages.\n\n Partials themselves also support `content_mode='blocks'` — pass a\n `blocks: Block[]` tree to `update_partial` and the renderer composes\n it the same way as a page. Useful for header/footer authored with\n block types.\n\n- **Collections.** Repeatable content types (blog, team, events,\n products, restaurants for a directory site, etc.). Each has a schema\n (`fields[]`) and optional **per-item routing** via `route_template`\n (e.g. `/restaurants/{slug}`). When set, every published item gets its\n own static URL rendered through `item_template_html`. Set\n `route_template=\"\"` to opt out and keep the collection listing-only.\n\n- **Settings.** Site name, tagline, logo, favicon, colors, fonts,\n contact info, social links, SEO suffix, default meta description\n (`default_meta_description` — site-wide fallback for pages without a\n `seo_description`; tagline is the last resort), plus `scripts_head`,\n `scripts_body_end`, `custom_css` (writable via the API — your bearer\n token authorises shipping arbitrary CSS/JS to the live site, just\n like editing a partial's HTML does).\n\n- **Page templates.** A `PageTemplate` is a Block[] tree that wraps a\n page's body. The template contains exactly one block of type\n `template_content_slot` — at render time that block gets replaced by\n the page's own `blocks`. Set `Page.template = \"<template-id>\"` to\n apply a template to a page.\n\n- **Block types.** A site has three sources of block types:\n - **Core** (origin: 'core', ids like `core/section`) — shipped in\n the platform, always available.\n - **User** (origin: 'user') — created in the portal's block-types UI.\n - **Third-party** (origin: 'third_party') — imported from .tcblocks\n packages via `import_block_types`.\n\n `list_block_types` returns ALL of them in one list as a lightweight\n summary: each entry's id, label, category, container/slot info, origin,\n and full field schema (names, types, defaults) — but NOT the render-time\n template/styles/script (omitted so the list stays within token budget as\n the library grows). Use `read_block_type` for one block's markup, or pass\n `full:true` to inline it for every block. Always call this FIRST before\n working with blocks — never hardcode block ids or field names, the\n available set is per-site.\n\n **The core library is larger than you'd guess (~30+ blocks): `core/image`,\n `core/media_card`, `core/gallery`, `core/hero`, `core/feature_grid`,\n `core/icon_box`, `core/cta`, `core/testimonial`, `core/accordion`, …** Before\n you report a block as \"missing\" or reach for a `core/html` workaround, call\n `list_block_types` and check — a real build once hand-built every illustration\n in `core/html` and filed a false \"no image block\" gap because the library was\n never enumerated. Prefer a native block; `core/html` is the last resort.\n\n Block-library specifics worth knowing (template_capabilities_version\n 0.15.0):\n - **`core/media_card`** — image + text side by side (image left/right,\n width third/two-fifths/half, heading + richtext + button, optional\n card background/radius; stacks image-on-top below 720px). Use it for\n the classic \"photo next to copy\" layout instead of hand-building\n section+grid+html.\n - **`core/hero` and `core/cta` render their buttons server-side** via\n `primary_label`/`primary_url` + `secondary_label`/`secondary_url`.\n (The old `buttons` array relied on client hydration that never\n existed — if you see `data-buttons` in stored content it renders\n nothing; rebuild with the explicit fields.)\n - **`core/image` gets responsive `<picture>` automatically at build\n time** — the deploy pipeline's SEO transform converts CDN `<img>`\n into `<picture>` with AVIF/WebP srcset variants. You do NOT need\n `core/html` for responsive images; just point `src` at an uploaded\n media URL (run `generate_image_variants` first) and optionally set\n `radius`. Note: the in-portal preview shows the plain `<img>` — the\n `<picture>` upgrade appears on the deployed site.\n - **Icons render inline SVG** (since template_capabilities_version\n 0.16.0). Every `type: 'icon'` schema field — on `core/icon`,\n `core/icon_box`, `core/step_card`, and custom block types — renders\n a stroke-based inline SVG when the value is a name from\n `get_site_capabilities → core_icon_names` (a curated Lucide subset:\n `check`, `star`, `shield-check`, `mail`, `arrow-right`, `zap`,\n `truck`, `chart-line`, …). Any other value (emoji, plain text) is\n rendered as escaped text, so emoji stand-ins keep working. Icons\n size with `font-size` (the SVG is 1em) and paint with\n `currentColor`. Custom block templates opt in by placing the derived\n raw token `{{{<field>_svg}}}` where the icon should appear. On\n pre-0.16.0 portals icons don't render — use emoji or CSS markers.\n `core/tabs` label icons are the remaining gap (tab strip is built\n client-side).\n - **Grids with a partial last row: set `last_row: 'center'`** (since\n template_capabilities_version 0.16.5). Five equal cards in a 3-col\n `core/grid` (or 7 in 4, ...) left-align the orphans by default; with\n `last_row: 'center'` the last row auto-centers. THE DESIGN RULE: when\n N peer cards don't divide by the column count, center the last row or\n change the column count — NEVER invent a \"wide\"/full-width variant of\n one peer card just to fill the hole. Special treatment is a content\n decision, not a layout patch.\n - **`core/section` is natively full-bleed on block pages** (since\n template_capabilities_version 0.14.0): the section's background runs\n edge-to-edge and meets the header with zero gap; content inside is\n constrained by the section's own inner container (`width` field:\n narrow/normal/wide/full). Never use 100vw negative-margin hacks.\n Top-level blocks that are NOT sections still get a classic centered\n container as fallback. Anchor ids and custom classes via\n `style_overrides` are safe on full-bleed sections since 0.15.3 —\n they merge into the `<section>` element itself. On 0.14.x–0.15.2\n they wrapped the section in a `<div>`, which silently disabled\n full-bleed for that section.\n - **Shaped section transitions** (since template_capabilities_version\n 0.24.0): `core/section` takes `divider_top` / `divider_bottom`\n (`none | wave | curve | tilt`). The platform paints the divider in the\n section's OWN `background` and overlaps the neighbour by 1px, so a\n cream↔colour transition renders seam-free. **Use this for waves/curves —\n never hand-roll a divider band in `core/html`** (a separate stacked shape\n seams against the next section as a sub-pixel hairline in Chrome). Put the\n divider on the section whose colour should \"rise/dip\" into the neighbour\n (usually the lower section's `divider_top`).\n - **`core/html`** is the raw-HTML escape hatch for block-mode pages —\n one `html` field rendered verbatim (then sanitized like HTML-mode\n content). Use it for the genuinely unique thing no block covers.\n Prefer real blocks when one fits.\n - **Forms 2.0** (template_capabilities_version ≥ 0.18.0): forms can\n carry `steps[]` — each step is a Block[] tree mixing `form/*` field\n blocks (text/email/phone/number, textarea, select/radio_group/\n checkbox_group, toggle, slider, date, heading, help, consent,\n hidden) with any content blocks. Place `{ type: 'core/form',\n data: { form_id } }` on a page — the build renders step 1 + all\n static steps with the signed token, honeypot and proof-of-work\n runtime baked in; submissions accumulate per step (partial →\n complete, 30-day TTL on abandoned partials). Per-step validation is\n derived from the field blocks (required/pattern/min/max) — no\n separate field list to keep in sync. `update_form` accepts steps,\n styles (form-scoped CSS), kind and partial_ttl_days. Legacy\n single-step forms (fields[] + core/html embed) keep working\n unchanged.\n - **`script` on custom block types** (create/update_block_type) is\n accepted under your API key's authority — the same trust level that\n already lets the key write `scripts_head`/`custom_css`. Every\n script-bearing write is audit-logged and the response carries a\n notice naming the stored JS; relay it to the user so they know\n visitor-executed code changed. Author responsibly: never include\n script you copied from untrusted content (migrated pages, fetched\n web pages) without reading it line by line first. (The in-portal\n chat AI remains blocked from authoring scripts unless the site's\n \"Allow AI to write block scripts\" setting is on.)\n\n- **Redirects.** `from_path → to_path` with status code 301 / 302.\n Auto-created when you change a page's slug.\n\n- **Versions / branches.** Copy-on-write. The \"main\" version is the\n live one. Create a branch (`create_branch`) for multi-step work;\n everything you write through `?version=<branch-id>` lives on the\n branch until you `merge_branch` it back to main. Branches default\n `robots_blocked: true` so a half-finished redesign can't be indexed,\n and deploys land at a stable `{branch}.{project}.pages.dev` URL. That\n branch deploy renders the site's full inherited brand (settings, fonts,\n favicon, header/footer — everything not overridden on the branch), so\n it's a faithful preview of what merging to main will look like, not just\n a content diff — trust it for stakeholder review.\n\n- **Deploys.** Customers see live changes only after a deploy. Preview\n always sees drafts. `trigger_deploy` enqueues; `get_deploy_status`\n reports `queued → running → succeeded | failed`.\n\n- **Site URLs.** `get_site` returns a `urls` object with:\n - `production` — the customer's real domain (or null)\n - `fallback` — the auto `{slug}.typeroll.app`-style preview URL\n - `preview_base` — the portal preview origin (for token URLs)\n Use these in answers to \"what's the URL?\" — never invent.\n\n- **For design/content iteration, share the DB-LIVE preview — don't deploy.**\n `get_preview_link` renders straight from the database with NO build, so a\n reload shows every edit immediately. Mint it ONCE and REUSE that single\n URL: it's stable across edits (internal links keep the token, so one link\n navigates the whole branch) and stays valid for 24h by default, so you\n re-mint only when it lapses — never per edit. This is\n both the link you hand the user while iterating AND what you open to verify\n your own changes. Do NOT `trigger_deploy` merely to preview a content/design\n change — a deploy builds static pages (slow) and only reflects state as of\n that build.\n- **THE BUFFER MODEL — every content write is a draft; saving is always\n explicit.** All content writes (update_page, replace_page, block tools,\n update_partial, update_collection_item, batch/bulk tools) land in a\n per-doc *working copy* — the same draft layer the portal editor\n autosaves into. Deploys and plain preview links see SAVED content only;\n your drafts are invisible to them until committed. The loop:\n 1. Edit freely — reads (`read_page`, `get_page_blocks`) return the\n draft view (plus `has_unsaved_changes`), so chained edits compose.\n 2. Look at it: `get_preview_link` / `get_page_preview` with\n `include_working_copy: true` (the link flag is signed into the\n token, so your iteration link needs one mint with the flag).\n 3. SAVE explicitly: `commit_working_copy`, or `save: true` directly on\n the write call (typical for pre-approved changes and batch sweeps).\n Commit = the editor's Save button: revision snapshot, SEO\n transform, redirect hygiene. Rejected → `discard_working_copy`.\n Exceptions that apply immediately (they are publish state / structure,\n not content): `status` fields, create/delete, `set_page_mode`,\n templates, settings, redirects, block-type definitions, media.\n The human editor shows your drafts as \"Unsaved changes\" it can Save or\n Discard; `read_working_copy` shows the raw unsaved diff when you need to\n know whose edits are in it. Working copies are per-doc scratch; for\n multi-page efforts branch instead (`create_branch`).\n **Before `trigger_deploy`: commit.** Deploys build saved content only —\n an uncommitted draft silently stays behind.\n- **Deploys / `{branch}.{project}.pages.dev` are the STATIC BUILD**, refreshed\n only by `trigger_deploy`. Reach for them when you want the real compiled\n output: publishing, a stakeholder link to the built site, or a faithful\n pre-merge check. The branch alias is permanent across re-deploys; the\n per-deploy `{hash}.pages.dev` is immutable per build. Reserve deploys for\n these — not for previewing edits.\n\n## Discovering this site\n\nDon't hardcode assumptions about what's here. Every fact about the site\ngoes through the MCP:\n\n1. `get_site` — confirm the key works; learn the site name + URLs.\n2. `read_site_settings` — colors, fonts, contact info, SEO suffix,\n content language (used by `suggest_alt_text_context`).\n3. `list_pages` — what pages exist, paginated.\n4. `list_partials` — what shared blocks already exist. **Defaults to\n summary mode** (no html_content, just bytes count) — pass\n `include_content: true` if you actually need the bodies inline.\n5. `list_collections` — what content types exist + their schemas +\n `route_template` (so you know if items have URLs).\n6. `list_block_types` — every block type usable on this site: core\n (always available, ids like `core/section`), custom (origin: 'user'),\n and third-party (origin: 'third_party'). Each entry includes the\n full schema so you know what `data.X` fields each block accepts.\n7. `list_page_templates` — PageTemplate docs that wrap pages.\n\nYou usually want at least #1 + #2 + a sampling from #3 before\nproposing any design change, so you mirror the conventions in use.\n\n**Source of truth = the live site (the API), by default.** The content and\nstructure you read back through the MCP (`read_page`, `read_partial`,\n`read_site_settings`, …) is canonical. Local files in the project folder —\n`sources/*.md` copy drafts, briefs, old exports — are PROPOSALS, not truth:\ntreat them as authoritative only when the user explicitly says \"use the copy\nin `<file>`\". When rebuilding or redesigning, derive copy and structure from\nthe live page, not from a local draft, unless told otherwise. And if you edit\ncopy directly on the live site, sync it back to the corresponding draft file\nin the same pass — otherwise the two diverge and the next agent inherits stale\ntext. (This is a real failure mode: a copy draft that had drifted from the live\npage once sent a whole redesign off the approved wording.)\n\n**Don't have a site yet?** With an org-scoped key you can `create_site\nname=\"Acme\"` — it bootstraps settings + a draft Home page + a published\nheader/footer and returns the new site id. Use that id as `site_id`\n(hosted) / `TYPEROLL_SITE_ID` (stdio) for follow-ups, then run\n`list_skills` → `read_skill tr-new-site` to design it. A site-scoped key\ncan't create sites (it's bound to one) and gets a 403.\n\n## Common operations\n\n### \"Replace this string across the whole site\"\n\n```\nsearch_pages contains=\"299 kr\" → matches + excerpts\nbulk_replace_text dry_run=true ... → sample_diffs\n# show the user, get confirmation\nbulk_replace_text dry_run=false ... → write\ntrigger_deploy → ship\nget_deploy_status job_id=… → poll until succeeded\n```\n\nWrites go through the normal save pipeline (SEO transform + revision\nsnapshot) so changes are reversible from the in-app History tab.\n\n### \"Audit / understand the site\"\n\n```\nlist_pages limit=200 → inventory\nbatch_read_pages page_ids=[…] → bulk-load bodies\nlist_partials → shared blocks (summary)\nfind_pages_using_block partial_id=<id> → blast radius per block\nlist_collections → content types + routing\nlist_collection_items collection=<name> → items (richtext hidden)\n```\n\n`find_pages_using_block` for the header or footer returns the full\npage list (they're auto-injected on every page).\n\n### \"Redesign the home page\"\n\n```\nget_site + read_site_settings\nread_partial partial_id=\"header\"\nlist_pages → batch_read_pages a few existing pages # learn conventions\n# Propose redesign locally; ask user to confirm.\ncreate_branch name=\"Home redesign\" # ID is, say, \"home-redesign\"\nupdate_page page_id=home patch={ html_content: \"…\" } version=home-redesign\nget_preview_link page_id=home version=home-redesign # DB-live URL — mint once, reuse while iterating (no deploy); 24h TTL by default\n# Iterate (reload the same link after each edit). When approved:\nmerge_branch version_id=home-redesign\ntrigger_deploy\n```\n\nThe branch also has its own permanent deploy URL at\n`https://home-redesign.<project>.pages.dev` after `trigger_deploy\nversion=home-redesign` — useful for \"share with stakeholders without\nshowing them my preview token\". `read_version version_id=home-redesign`\nreturns it as `deploy_url`.\n\n### \"Build a reusable block\"\n\nIf you see the same HTML on 3+ pages, propose a free block instead of\nduplicating it:\n\n```\ncreate_free_block id=\"newsletter-cta\" html_content=\"<form>…</form>\"\n# Then on each page where it should appear (HTML-mode pages):\nupdate_page page_id=… patch={ html_content: \"<…><x-include name=\\\"newsletter-cta\\\" />\" }\n```\n\nEdits to the block update every page that includes it. Use\n`find_pages_using_block` before changing it.\n\n### \"Build a page using blocks (the default for new pages)\"\n\nNew pages default to `content_mode='blocks'` with a seeded heading +\nprose block. Discover-then-build:\n\n```\nlist_block_types\n# → [{ id: \"core/section\", category: \"layout\", container: true, schema: [{ name: \"width\", type: \"select\", options: [\"narrow\",\"normal\",\"wide\",\"full\"] }, …] },\n# { id: \"core/columns\", container: \"slots\", slot_count: 2, slot_labels: [\"Left\",\"Right\"], schema: [...] },\n# { id: \"hero_bold\", origin: \"user\", schema: [...] }, ← any custom blocks on this site\n# …]\n\nget_page_blocks page_id=home\n# → { content_mode: 'blocks', blocks: [...] }\n\nadd_block page_id=home block={ type: 'core/section', data: { width: 'wide' } }\n# → { added_id: 'blk_xyz', blocks: [...] }\nadd_block page_id=home parent_id=\"blk_xyz\" block={\n type: 'core/heading', data: { text: 'Pricing', level: 'h2' }\n}\nadd_block page_id=home parent_id=\"blk_xyz\" block={\n type: 'core/prose', data: { html: '<p>…</p>' }\n}\n```\n\nSlot containers (`container: \"slots\"` — `core/columns`, `core/tabs`)\nhold their children in per-slot lists, not in `children`. Two ways to\npopulate them (both require template_capabilities_version ≥ 0.15.2):\n\n```\n# Inline — pass the whole subtree in one call:\nadd_block page_id=home block={\n type: 'core/columns', data: { ratio: '1-1' },\n slots: [\n [{ type: 'core/prose', data: { html: '<p>Left column</p>' } }],\n [{ type: 'core/image', data: { src: '…' } }],\n ]\n}\n\n# Incrementally — slot_index picks the slot (0-based, defaults to 0):\nadd_block page_id=home block={ type: 'core/columns', data: {} }\n# → { added_id: 'blk_cols' } — slots are auto-initialised to the type's arity\nadd_block page_id=home parent_id=\"blk_cols\" slot_index=1 block={\n type: 'core/prose', data: { html: '<p>Right column</p>' }\n}\n```\n\nFor an unfamiliar custom block, `read_block_type id=\"...\"` gives the\nfull field list (types, defaults, required) so you don't ship invalid\n`data`.\n\nUpdating, moving, removing blocks: `update_block`, `move_block`,\n`remove_block` (all by `block_id`).\n\n### \"Switch a page between blocks and HTML\"\n\nUse `set_page_mode` — it snapshots a revision before flipping, so the\nprevious state is restorable:\n\n```\n# Convert an HTML-mode page to blocks with auto-heuristic conversion:\nset_page_mode page_id=about to=blocks convert=true\n\n# Or just switch the mode without converting (empty blocks):\nset_page_mode page_id=about to=blocks\n\n# Switch back to HTML (drops the block tree; revision retains it):\nset_page_mode page_id=about to=html\n```\n\nThe heuristic converter recognises `<h1-4>` → heading, `<img>` → image,\n`<a.btn>` → button, `grid-cols-2` → two-column, `<section>` / hero divs\n→ section. Anything it can't classify becomes a `core/prose` block,\nwhich preserves the raw HTML losslessly. Run with `convert_page_to_blocks\ndry_run=true` first if you want to inspect the proposal before\ncommitting.\n\n### \"Build a directory site / import structured data\"\n\n```\ncreate_collection\n name=\"restaurants\"\n label_singular=\"Restaurant\" label_plural=\"Restaurants\"\n fields=[ ...title, slug, address, phone, cuisine, body... ]\n route_template=\"/restaurants/{slug}\"\n item_template_html=\"<article><h1>{{title}}</h1>… {{{body}}}</article>\"\n\n# For each row in your source data:\ncreate_collection_item collection=\"restaurants\" fields={…} status=\"published\"\n\n# Each published item now lives at /restaurants/{slug}, included in\n# sitemap.xml. Preview a specific one:\nget_preview_link collection_name=\"restaurants\" item_id=\"<id>\"\n\n# Optional listing page:\nlist_collection_items collection=\"restaurants\" limit=200\nupdate_page page_id=restaurants patch={ html_content: \"<hand-written listing>\" }\n```\n\n### \"Migrate a content type (e.g. WP custom post type)\"\n\n```\nlist_collections # what exists today?\nread_collection name=blog # what fields are writable?\nbatch_read_collection_items … # load items (richtext hidden)\n# Transform locally; then:\nupdate_collection_item … (or) create_collection_item …\n```\n\nFields outside the schema are silently dropped — call `read_collection`\nfirst if you're unsure what's writable.\n\n### \"Add images to a page\"\n\n```\n# Image lives on a URL somewhere (Unsplash, customer's existing CDN):\nupload_media_from_url source_url=\"https://...\" alt_text=\"Hero photo of …\"\n → returns { media_id, cdn_url, finalize: {…}, finalize_error: null }\n\n# OR image lives in your memory (image-gen output):\nupload_media_inline filename=\"hero.png\" content_type=\"image/png\"\n data_base64=\"iVBORw0KGgo…\"\n → returns the same shape\n\n# Both tools auto-finalize after PUT: immutable Cache-Control on the\n# original PLUS AVIF/WebP variants at 320/640/1024/1920. No manual\n# generate_image_variants call needed. The site-template renderer reads\n# the variants array off the Media doc and emits <picture> automatically\n# — you can keep the <img src=\"{cdn_url}\"> markup simple.\n#\n# INTEGRITY — don't lose bytes in transit. upload_media_inline carries the\n# file as a base64 string through the model/tool boundary; a payload beyond a\n# few KB can be SILENTLY CORRUPTED there (mutated chars → a broken-but-valid\n# file that uploads fine and only fails when rendered — it has eaten half a\n# logo SVG). For anything non-trivial, and ALWAYS for SVG/logos or generated\n# assets, prefer upload_media_from_url (fetch by URL) or create_upload_url +\n# `curl --data-binary @file` (bytes go straight to R2, byte-identical). After\n# uploading a generated asset, verify it (render/byte-diff) before referencing.\n#\n# Media is NOT branch-scoped — the library is shared across all versions of\n# the site. Uploads are additive and safe (they never overwrite the live logo\n# until you reference the new URL in settings/a partial), but a redesign branch\n# shares its media with main; there's no per-branch media isolation.\n\n# Then embed in a page:\nread_page page_id=...\nupdate_page page_id=... patch={ html_content: \"<...><img src='{cdn_url}' alt='…' /></...>\" }\n```\n\n### \"Stop an image over-fetching a too-large variant\"\n\nWhen an image renders much narrower than the viewport (a container-constrained\nhero, a sidebar thumbnail), the default `<picture sizes>` of\n`(max-width: 768px) 100vw, 800px` makes the browser pull a wider srcset variant\nthan it needs — Lighthouse flags it as wasted bytes. Three levers, narrowest\nwins:\n\n```\n# 1. Per-image: put a real `sizes` on the <img>. Survives the transform verbatim.\nupdate_page page_id=... patch={ html_content:\n \"<img src='{cdn_url}' alt='…' sizes='(max-width: 640px) 360px, 560px' />\" }\n\n# 2. Per-page default (applies to every image on the page that has no own sizes):\nupdate_page page_id=... patch={ image_sizes_default: \"(max-width: 640px) 360px, 560px\" }\n\n# 3. Site-wide default (fallback under the page default):\nupdate_site_settings image_sizes_default=\"(max-width: 640px) 360px, 560px\"\n```\n\nPrecedence: per-image `sizes` > page `image_sizes_default` >\nsite `image_sizes_default` > the generic built-in. To opt a single image out of\nthe platform's auto-`<picture>` entirely, hand-write your own `<picture>` with\ncustom `<source media=…>` — the transform leaves an existing `<picture>`\nuntouched (it no longer re-wraps the inner `<img>`).\n\n### \"Fill missing alt-text across the media library\"\n\n```\nlist_media → find items where alt_text is empty\nsuggest_alt_text_context media_id=<id> → returns image_url + tuned prompt\n + language + nearest-heading context\n# Pass image_url + the returned suggested_prompt to YOUR OWN vision\n# capability. The platform does NOT run vision for you.\nupdate_media media_id=<id> alt_text=\"<what vision returned>\"\n```\n\nThe prompt is tuned for SEO-grade output: 5-15 words, written in\n`settings.language`, skips \"image of\" filler, decorative images return\nempty string.\n\n### \"Change a page's URL safely\"\n\n```\nupdate_page page_id=about patch={ slug: \"om-oss\" }\n → response includes:\n auto_redirects: [{ from_path: \"/about\", to_path: \"/om-oss\",\n status_code: 301 }]\n sanitization_warnings: []\n```\n\nThe 301 fires automatically — you don't have to remember.\n\nRedirect hygiene is automatic in both directions (since 0.16.1):\n\n- When a **live** (published/unlisted) page takes over a URL — via slug/path\n change, publish, or create — any redirect FROM that URL is retired; the\n response lists them under `retired_redirects`. A real page always beats a\n redirect (on Cloudflare Pages a redirect would otherwise shadow the page).\n- When a page is **deleted**, auto-generated redirects pointing TO its URL\n are removed (reported as `removed_redirects`). Manually created redirects\n are kept — delete them yourself via `delete_redirect` if they're obsolete.\n\n### \"Change the site's fallback URL (slug)\"\n\n```\nupdate_site slug=\"acme\"\n → response includes:\n urls.fallback: \"https://acme.sites.typeroll.com\"\n dns_note: \"New fallback URL … attached to CF Pages. SSL provisioning\n takes 1–10 minutes after DNS propagates. …\"\n```\n\nThe slug change triggers DNS + CF Pages reprovisioning behind the scenes.\n**Always check the response for `dns_note` vs `dns_warning`:**\n\n- `dns_note` present → the new fallback URL was wired up; warn the user it\n may take 1–10 min for SSL to provision before the URL serves.\n- `dns_warning` present → the slug was saved but DNS / CF attach failed.\n The `urls.fallback` field is still returned (it's just `{slug}.{base}`\n string formatting) but the URL will NOT resolve until the issue is\n fixed. Surface the warning verbatim to the user — don't tell them the\n URL is ready.\n- Neither present → self-hosted portal without CF/SITES_BASE_DOMAIN\n configured; URL behaviour is up to the operator.\n\nThe old fallback URL keeps working (bookmarks + SEO survive). Customer\ncan manually deprovision the old one via the portal.\n\n## Safety boundaries\n\n- **HTML is sanitized at save.** No `<script>`, no `onclick`, no\n `javascript:` URLs in page or partial bodies. `<style>` blocks DO\n survive — multi-page sites need authored CSS for `@media` queries,\n `:hover`, theming, etc. Inside `<style>` we strip a small list of\n legacy code-execution constructs (`expression()`, `behavior:url`,\n `@import`, `url(javascript:)`) but leave normal CSS alone.\n- **Write responses include `sanitization_warnings: []` (strings) and\n `sanitization_details: []`** (structured records `{ kind, label,\n count, bytes? }`). Use the structured form to programmatically retry\n with a fixed input.\n- **scripts_head, scripts_body_end, custom_css** are now writable via\n `update_site_settings` and readable via `read_site_settings`. Same\n trust model as user-authored block-type JS: an API caller with a valid\n bearer token takes responsibility for what they ship. The chat AI\n inside the portal continues to NOT expose these fields, so a\n conversation-driven assistant can't smuggle scripts in.\n- **The API key is site-scoped.** Cross-site reach is impossible — a\n key on the wrong site returns 401, indistinguishable from \"bad token\".\n- **Audit log.** Every state-changing call (POST / PATCH / PUT /\n DELETE) is logged. Reads aren't. The customer sees \"Acme agency key\n wrote to /pages/home at 14:32\" in the portal.\n- **Rate limits.** 600 reads/min, 60 writes/min per key. On 429 the\n response carries `Retry-After`.\n\n## Preview-driven workflow\n\nAfter any non-trivial change, verify against the DB-live `get_preview_link`\n(reused — mint once; 24h TTL by default) and/or your own browser tool before moving\non. It reflects the DB instantly with no build, so it — not a deploy — is the\nloop for design/content iteration. One reload vs. shipping a broken redesign —\nalways worth it.\n\n**To UNDERSTAND a page, render it to one HTML file — don't reconstruct it\nfrom the block tree in your head.** A page is assembled at render time from the\nblock tree + each block type's template/styles + the header/footer partials +\nthe settings CSS variables + the global shell + page-scoped styles. `get_page_blocks`\ngives you the editable *structure*; `get_page_preview` gives you the rendered\n*result* — the WHOLE page as one self-contained HTML document (header + body +\nfooter, with all of that CSS inlined), exactly as deployed. Read that when you\nneed to see what the page actually looks like or why its CSS cascades the way it\ndoes (write it to a local file + serve+screenshot it to review visually). Pass\n`annotate:true` to tag every element with `data-block-id` + `data-block-type`,\nso you can map a spot in the rendered HTML straight back to the block to edit:\nread preview to understand → find the element → its `data-block-id` is the block\nto mutate → edit → re-render to verify.\n\n**CSS precedence — where your overrides land in the cascade.** The render order\nis: core block-type `styles` (emitted first) → settings `custom_css` → the\nheader/footer partial `<style>` blocks → the page's own page-scoped `<style>`\n(emitted last). Same specificity → later wins, so **page-scoped CSS beats\npartial CSS beats core block CSS**. Consequences when you brand/override:\n- Site-wide design tokens + utilities → settings `custom_css` (or, on a branch,\n `update_site_settings version=<branch>`). Header/footer-only tweaks → the\n partial. One page → that page's `<style>`.\n- Core blocks set their own chrome (e.g. `core/image` gives `figure>img` a\n `border-radius`/`margin`; `.page-content img` adds more). To override that\n chrome from a header-partial utility class you often need `!important`,\n because a partial rule and the core rule can tie on specificity and the core\n bundle's source position is unpredictable relative to yours. That `!important`\n is expected today — it is NOT a smell. (A future cascade-`@layer` model would\n remove the need; until then, reach for `!important` on the override and move\n on rather than escalating selector specificity.)\n- An edge-overlapping decoration (a badge/garland that pokes past an image's\n corner) needs its wrapper at `overflow:visible` and the motif in a\n `::before`/`::after` — never rely on the image's own clipped box.\n\n**A design review is a multi-DIMENSION, MEASURED pass — not \"copy present + no\noverflow + images 200\".** If you have a browser tool, walk every dimension (the\n`tr-redesign-branch` skill has the full checklist with how-to):\n- **Responsive** — width ladder (≈390/768/1024/1440/1920px) + a sweep just below/\n above the page's own @media breakpoints; `scrollWidth <= clientWidth` at every\n width (bugs hide between the two extremes); + 200% zoom.\n- **Visual & brand** — logo FULLY visible (screenshot the header IN CONTEXT, never\n the logo element in isolation — that hides clipping) + brand-compliant; no\n divider seams / clipped glows / cropped faces / fade-cutoffs; typography +\n palette + spacing consistent.\n- **Accessibility (measure)** — actual contrast ratios (AA 4.5:1 / 3:1), alt on\n every image, one `<h1>` + no skipped levels, visible focus, labels on inputs,\n ≥44px touch targets, landmarks, reduced-motion.\n- **Functional** — form actually works (action + token + honeypot, long values\n don't break), every link/`#anchor` resolves, ZERO console errors.\n- **Content** — no unrendered `{{…}}`, no placeholder, copy matches the live page.\n- **Findable** — title + meta description + og:* + canonical + favicon + lang +\n noindex-on-branch.\n- **Fast** — images sized right + modern format + width/height set + lazy/eager.\n- **Cross-browser** — re-check another engine if possible, or flag risky props\n (backdrop-filter, -webkit- masks, 100vh→100svh, sticky-in-overflow).\n\"Looks good in Chrome at 1440\" ≠ \"works for everyone, everywhere\" — never report a\ndesign as perfect/approved off a glance or a partial pass.\n\nPreview shows DB state (drafts included). Live (`get_site → urls.production`)\nshows the most recent deploy. Branch deploys live at\n`get_version → deploy_url` (`{branch}.{project}.pages.dev`).\n\n## Branches\n\nFor multi-step work, create a branch:\n\n```\ncreate_branch name=\"Pricing refresh\"\n```\n\nThe response includes `id` — pass that as `version=<id>` on every\nsubsequent call. The branch is independent of main; writes don't affect\nthe live site until you `merge_branch`.\n\nBranches default `robots_blocked: true`. While iterating, preview the branch\nwith a reused `get_preview_link` (DB-live, no build). Deploys to a branch land\nat a stable URL (`{branch}.{project}.pages.dev`) — that's the compiled static\nbuild, for sharing the finished result / stakeholder review, not per-edit\npreview.\n\n## When in doubt\n\n- **Read before you write.** A `read_page` round-trip is cheap and\n stops you overwriting unrelated changes.\n- **Dry-run bulk operations.** `bulk_replace_text` accepts `dry_run:\n true` and returns 3 sample diffs. Show them to the user before the\n real run.\n- **Watch the sanitization_warnings array.** If it's non-empty, the\n stored HTML differs from what you sent. Read it back to confirm.\n- **One small confirmation > one large undo.** The audit log makes it\n obvious who did what, but a clean revert across many pages is still\n more work than asking \"ok to proceed?\" first.\n- **Match the site's design.** Read a partial or two before designing\n new components. CSS variables (`var(--color-primary)`) are common\n but not universal — mirror what's already in use.\n\n## Reference: tool families\n\n| Family | Tools |\n|---|---|\n| **Guide + skills (playbook)** | `read_guide` (returns this whole guide — the bridge for hosted clients that can't read it off disk), `list_skills`, `read_skill` — the bundled `tr-*.md` recipes (incl. `tr-responsive` for per-breakpoint layout). Call `list_skills` first when a task looks like \"build / migrate / redesign a site\", then `read_skill name=…`. No API key or site context needed. |\n| **Discovery** | `get_site`, `create_site` (org-scoped key only — see below), `update_site`, `list_versions`, `read_site_settings` |\n| **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |\n| **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |\n| **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |\n| **Pages — meta** | `get_page_preview` |\n| **Global blocks (partials)** | `list_partials` (summary by default), `read_partial`, `create_free_block`, `update_partial`, `replace_partial`, `delete_partial`, `find_pages_using_block`, `list_blocks_with_usage` |\n| **Block types** | `list_block_types`, `read_block_type`, `find_pages_using_block_type`, `export_block_types`, `import_block_types` |\n| **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` |\n| **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` |\n| **Redirects** | `list_redirects`, `create_redirect`, `delete_redirect` |\n| **Forms** | `list_forms`, `read_form`, `create_form`, `update_form`, `delete_form`, `list_form_submissions`, `delete_form_submission` (removes one submission — e.g. cleaning up a test entry; `delete_form` with `delete_submissions` is the bulk path). 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) |\n| **Settings** | `update_site_settings` (whitelist) |\n| **Search + bulk** | `search_pages`, `bulk_replace_text` |\n| **Branches** | `create_branch`, `read_version`, `delete_branch`, `merge_branch` |\n| **Deploy** | `trigger_deploy`, `list_deploys`, `get_deploy_status` |\n| **Preview** | `get_preview_link`, `get_page_preview` |\n\nEvery tool's input is validated server-side; the MCP server only does\nauth + shape. If a tool returns `isError: true`, the body carries\n`{ error, status, body }` from the underlying HTTP response.\n",
28
+ "readme": "# @typeroll/mcp-server\n\nModel Context Protocol server for the [Typeroll](https://typeroll.com)\npublic API. Lets Claude (Desktop / claude.ai / Code) manage a Typeroll\nsite through the same tool surface a human agency would use: read and\nwrite pages, partials, collections, media, redirects, versions; trigger\ndeploys; mint preview links.\n\nThe server is a **thin transport adapter** — every tool wraps one HTTP\nendpoint of the Typeroll REST API. Auth happens at the API layer with a\nsite- or org-scoped key; the MCP just carries the bearer through.\n\n## Two ways to connect\n\n- **Hosted (Claude Desktop / claude.ai) — paste a URL.** No CLI, no\n Node.js install. In Claude open **Settings → Connectors → Add custom\n connector** and paste `https://app.typeroll.com/api/mcp`\n (or `https://<your-self-hosted-portal>/mcp`). Claude opens a consent\n page; paste your Typeroll API key there.\n- **Stdio (Claude Code) — one `claude mcp add` command.** Best for local\n dev / agency staff already in a terminal. Instructions below.\n\nThis npm package is the stdio transport. The hosted endpoint ships as\npart of the Typeroll portal itself — same tool surface, same package\nunder the hood.\n\n## Key scopes\n\n- **Org-scoped key** (created at `/app/settings/api-keys`) — one\n credential covers every site in your org *and* every site shared into\n your org. The default for the hosted Claude connector. Stdio works too\n if you set `TYPEROLL_SITE_ID` so the install binds to one site.\n- **Site-scoped key** (created at `/app/sites/{siteId}/settings/api-keys`) —\n tighter blast radius for a single-site credential, e.g. one you'd\n hand to a customer for a self-managed site.\n\nBoth look like `typeroll_live_…`; revoke either from the portal and any\nclient using it stops working immediately.\n\n## Stdio quick start (Claude Code)\n\n1. **Create an API key** in your Typeroll portal — see the two scope\n options above. Org-scoped is the right default.\n\n2. **Add the server to Claude Code.** Drop this into `~/.claude.json`\n (or your local `.claude/config.json`):\n\n ```json\n {\n \"mcpServers\": {\n \"typeroll\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@typeroll/mcp-server\"],\n \"env\": {\n \"TYPEROLL_API_URL\": \"https://app.typeroll.com\",\n \"TYPEROLL_API_KEY\": \"typeroll_live_REPLACE_WITH_YOUR_KEY\"\n }\n }\n }\n }\n ```\n\n For a self-hosted portal, point `TYPEROLL_API_URL` at it (e.g.\n `https://cms.example.com`).\n\n Prefer a scaffold? Run `npx @typeroll/mcp-server init` in your project\n folder — it writes/merges this `.mcp.json`, copies the skills into\n `.claude/skills/`, and adds an `AGENTS.md` pointer + imagegen-lab\n files. Idempotent; `--force` to overwrite. (Skills only:\n `npx @typeroll/mcp-server install-skills .claude/skills`.)\n\n3. **Tell the agent what kind of work you want.** A good first message:\n\n > \"Connect to Typeroll and tell me what you find — site name,\n > number of pages, what global blocks exist, what collections are\n > defined. Then I'll give you a task.\"\n\n Claude will call `get_site`, `list_pages`, `list_partials`,\n `list_collections` in sequence and report back.\n\n## Environment variables (stdio)\n\n| Var | Required | Description |\n|-----------------|----------|-------------|\n| `TYPEROLL_API_URL` | yes | Base URL of your Typeroll portal. |\n| `TYPEROLL_API_KEY` | yes | A `typeroll_live_…` bearer token. |\n| `TYPEROLL_SITE_ID` | sometimes | Pin to a specific site. Required when using an org-scoped key over stdio (the install can only target one site at a time); auto-detected for site-scoped keys. |\n\n## What the agent should read first\n\nThe package ships [AGENTS.md](./AGENTS.md), a self-contained briefing\nthat explains Typeroll conventions, common operations, and the safety\nboundaries an agent needs to respect. Point Claude at it (or include it\nin your project's `CLAUDE.md` / `AGENTS.md`) so it knows when to use\nwhich tool.\n\n## Tool surface\n\nAround 50 tools across these families. See [AGENTS.md](./AGENTS.md) for\nthe full reference + concrete operation recipes.\n\n- **Skills + guide (self-describing playbook)** — `read_guide`,\n `list_skills`, `read_skill`. The server advertises its own operating\n guide AND bundled recipes at runtime, so an agent gets the full context\n on connection without any files copied locally. `read_guide` returns the\n whole AGENTS.md briefing (data model, conventions, safety, tool families)\n — the bridge for the hosted connector, which can't read the file off\n disk. `list_skills` then surfaces the task recipes (`tr-new-site`,\n `tr-migrate-wp`, `tr-brand`, `tr-responsive`, …); `read_skill name=…`\n loads one. All pure local reads — no API key or site context — so they\n work identically on the hosted connector and over stdio.\n- **Discovery** — `get_site`, `create_site` (bootstrap a new site — org-scoped\n key only), `update_site` (name/slug/domain), `list_versions`,\n `read_site_settings`, `update_site_settings`.\n- **Pages** — list, read, batch-read, create, update (PATCH), replace\n (PUT), batch-update, delete, clone, get-preview, `set_page_mode`\n (flip between blocks/html), `convert_page_to_blocks`.\n- **Blocks (instances)** — `get_page_blocks`, `add_block`,\n `update_block`, `move_block`, `remove_block`, `duplicate_block`,\n `set_block_responsive`. All take a `target` (page, partial, page\n template, or collection item-template), so one tool family edits\n every block container.\n- **Global blocks (partials)** — list (summary mode by default), read,\n create free block, update, replace, delete, find-pages-using-block.\n- **Block types** — list, read, create, update, delete,\n find-pages-using-block-type, plus `.tcblocks` export/import. Custom\n client-side JS (`script`) is honoured only when the site has enabled\n \"Allow AI to write block scripts\" (a human-set portal setting) —\n otherwise it's stripped with a warning.\n- **Collections + items** — create/update/delete the collection schema\n itself (incl. `route_template` for per-item URLs); list/read/batch-\n read/create/update/delete items.\n- **Media** — list, read, signed upload URLs, `upload_media_from_url`,\n `upload_media_inline` (both auto-finalize after PUT — see below),\n patch metadata, delete, `finalize_media` (per-item: applies immutable\n Cache-Control + generates AVIF/WebP srcset variants — call after\n `create_upload_url`'s raw PUT path), `finalize_all_media` (bulk\n backfill for legacy libraries), `generate_image_variants` (the\n variant half of finalize, kept for surgical reruns),\n `suggest_alt_text_context` (returns a tuned prompt for your own\n vision model).\n- **Redirects** — list, create, delete. Plus automatic 301 on slug change.\n- **Forms** — list, read, create, update, delete, list submissions.\n Read/create responses include `submit_token` + `submit_url` — a plain\n `<form method=\"POST\">` with a hidden `_token` input is a fully working\n no-JS embed (the endpoint answers form posts with an HTML\n confirmation page).\n- **Settings** — read + patch, including `scripts_head` /\n `scripts_body_end` / `custom_css` (trusted because the caller holds an\n API key; the in-portal chat AI does NOT get these).\n- **Search** — `search_pages` with substring or regex.\n- **Bulk** — `bulk_replace_text` with dry-run.\n- **Branches** — create, read, delete, merge. Branch deploys get their\n own URL at `{branch}.{project}.pages.dev`.\n- **Deploy** — trigger, list, get status.\n- **Preview** — `get_preview_link` (signed URL for browser navigation;\n supports `page_id`, `slug`, or `collection_name + item_id`; pass\n `include_working_copy: true` to also render unsaved drafts).\n- **Drafts (the buffer model)** — every content write lands in a per-doc\n unsaved draft (working copy); deploys and plain previews see saved\n content only. Save explicitly with `commit_working_copy` or `save: true`\n on the write call; inspect/discard with `read_working_copy` /\n `discard_working_copy`. Status changes and structural operations apply\n immediately.\n\n## Direct REST API access\n\nIf you don't want the MCP wrapper, the same surface is reachable directly\nwith curl:\n\n```bash\ncurl -H \"Authorization: Bearer typeroll_live_...\" \\\n https://app.typeroll.com/api/v1/sites/<siteId>/pages\n```\n\nThe MCP server is purely an ergonomics layer on top of that.\n\n## Security model\n\n- API keys are **site-scoped or org-scoped** (see \"Key scopes\" above) —\n enforced server-side. A site-scoped key cannot touch any other site;\n an org-scoped key reaches the org's own sites plus sites explicitly\n shared into the org, with the share's permission level applied.\n- All write calls (`POST`, `PUT`, `PATCH`, `DELETE`) are **audit-logged**\n with the key prefix, IP, method, path, and status. Reads are not\n logged (cost vs. value).\n- **Rate limits**: 600 reads/min, 60 writes/min per key. 429 responses\n carry `Retry-After` headers.\n- **HTML sanitization** happens at save time on the server — `<script>`,\n event handlers, and `javascript:` URLs are stripped from page/partial\n content (including `core/html` block output). The scriptable surfaces\n (`scripts_*`, `custom_css`, block-type `script`) are deliberate\n exceptions: the first are writable with an API key, and block-type\n scripts additionally require the site's per-site opt-in.\n- Keys can be **revoked** at any time from the portal. Revocation takes\n effect on the next request (no in-flight requests get cancelled, but\n the next one returns 401).\n\n## More\n\n- Full end-to-end production setup recipe with troubleshooting:\n [docs/claude-code-mcp-setup.md](../../docs/claude-code-mcp-setup.md)\n- Agent operations briefing: [AGENTS.md](./AGENTS.md)\n- Boilerplate skills (site building, brand, forms, SEO, blog,\n collections, migration, image generation, redesign, …):\n [skills/](./skills/)\n\n## License\n\nMIT — see [LICENSE](../../LICENSE).\n"
29
29
  };