@typeroll/mcp-server 0.25.2 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +34 -0
- package/dist/bundled-content.js +2 -2
- package/dist/tools/media.js +3 -3
- package/dist/tools/settings.js +15 -6
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/tr-brand.md +8 -0
package/AGENTS.md
CHANGED
|
@@ -481,6 +481,20 @@ upload_media_inline filename="hero.png" content_type="image/png"
|
|
|
481
481
|
# generate_image_variants call needed. The site-template renderer reads
|
|
482
482
|
# the variants array off the Media doc and emits <picture> automatically
|
|
483
483
|
# — you can keep the <img src="{cdn_url}"> markup simple.
|
|
484
|
+
#
|
|
485
|
+
# INTEGRITY — don't lose bytes in transit. upload_media_inline carries the
|
|
486
|
+
# file as a base64 string through the model/tool boundary; a payload beyond a
|
|
487
|
+
# few KB can be SILENTLY CORRUPTED there (mutated chars → a broken-but-valid
|
|
488
|
+
# file that uploads fine and only fails when rendered — it has eaten half a
|
|
489
|
+
# logo SVG). For anything non-trivial, and ALWAYS for SVG/logos or generated
|
|
490
|
+
# assets, prefer upload_media_from_url (fetch by URL) or create_upload_url +
|
|
491
|
+
# `curl --data-binary @file` (bytes go straight to R2, byte-identical). After
|
|
492
|
+
# uploading a generated asset, verify it (render/byte-diff) before referencing.
|
|
493
|
+
#
|
|
494
|
+
# Media is NOT branch-scoped — the library is shared across all versions of
|
|
495
|
+
# the site. Uploads are additive and safe (they never overwrite the live logo
|
|
496
|
+
# until you reference the new URL in settings/a partial), but a redesign branch
|
|
497
|
+
# shares its media with main; there's no per-branch media isolation.
|
|
484
498
|
|
|
485
499
|
# Then embed in a page:
|
|
486
500
|
read_page page_id=...
|
|
@@ -624,6 +638,26 @@ so you can map a spot in the rendered HTML straight back to the block to edit:
|
|
|
624
638
|
read preview to understand → find the element → its `data-block-id` is the block
|
|
625
639
|
to mutate → edit → re-render to verify.
|
|
626
640
|
|
|
641
|
+
**CSS precedence — where your overrides land in the cascade.** The render order
|
|
642
|
+
is: core block-type `styles` (emitted first) → settings `custom_css` → the
|
|
643
|
+
header/footer partial `<style>` blocks → the page's own page-scoped `<style>`
|
|
644
|
+
(emitted last). Same specificity → later wins, so **page-scoped CSS beats
|
|
645
|
+
partial CSS beats core block CSS**. Consequences when you brand/override:
|
|
646
|
+
- Site-wide design tokens + utilities → settings `custom_css` (or, on a branch,
|
|
647
|
+
`update_site_settings version=<branch>`). Header/footer-only tweaks → the
|
|
648
|
+
partial. One page → that page's `<style>`.
|
|
649
|
+
- Core blocks set their own chrome (e.g. `core/image` gives `figure>img` a
|
|
650
|
+
`border-radius`/`margin`; `.page-content img` adds more). To override that
|
|
651
|
+
chrome from a header-partial utility class you often need `!important`,
|
|
652
|
+
because a partial rule and the core rule can tie on specificity and the core
|
|
653
|
+
bundle's source position is unpredictable relative to yours. That `!important`
|
|
654
|
+
is expected today — it is NOT a smell. (A future cascade-`@layer` model would
|
|
655
|
+
remove the need; until then, reach for `!important` on the override and move
|
|
656
|
+
on rather than escalating selector specificity.)
|
|
657
|
+
- An edge-overlapping decoration (a badge/garland that pokes past an image's
|
|
658
|
+
corner) needs its wrapper at `overflow:visible` and the motif in a
|
|
659
|
+
`::before`/`::after` — never rely on the image's own clipped box.
|
|
660
|
+
|
|
627
661
|
**A design review is a multi-DIMENSION, MEASURED pass — not "copy present + no
|
|
628
662
|
overflow + images 200".** If you have a browser tool, walk every dimension (the
|
|
629
663
|
`tr-redesign-branch` skill has the full checklist with how-to):
|
package/dist/bundled-content.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/* eslint-disable */
|
|
6
6
|
export const BUNDLED_SKILLS = {
|
|
7
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### 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",
|
|
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",
|
|
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, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/\"/g, '"')\n .replace(/'/g, ''');\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
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",
|
|
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 at `ttl_seconds: 86400`, reuse) — 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",
|
|
@@ -24,6 +24,6 @@ export const BUNDLED_SKILLS = {
|
|
|
24
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"
|
|
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 with the long TTL\n (`ttl_seconds: 86400`, the 24h max) and REUSE that single URL: it's stable\n across edits (internal links keep the token, so one link navigates the whole\n branch), and you only re-mint when the 24h 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 ttl_seconds=86400 # DB-live URL — mint once, reuse while iterating (no deploy)\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# 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 at the 24h TTL) 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**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",
|
|
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 with the long TTL\n (`ttl_seconds: 86400`, the 24h max) and REUSE that single URL: it's stable\n across edits (internal links keep the token, so one link navigates the whole\n branch), and you only re-mint when the 24h 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 ttl_seconds=86400 # DB-live URL — mint once, reuse while iterating (no deploy)\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 at the 24h TTL) 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
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"
|
|
29
29
|
};
|
package/dist/tools/media.js
CHANGED
|
@@ -59,7 +59,7 @@ export const mediaTools = [
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
name: 'create_upload_url',
|
|
62
|
-
description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. REQUIRED follow-up: call `finalize_media` after the PUT succeeds — without it the original ships with no Cache-Control (Lighthouse will flag it) and no responsive variants get generated. The response carries `finalize_url` as a reminder.
|
|
62
|
+
description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. REQUIRED follow-up: call `finalize_media` after the PUT succeeds — without it the original ships with no Cache-Control (Lighthouse will flag it) and no responsive variants get generated. The response carries `finalize_url` as a reminder. This signed-PUT path is the INTEGRITY-SAFE way to upload bytes from disk or a generated asset — a direct `curl --data-binary @file` sends the bytes straight to R2 without transcribing them through the model, so it can\'t be corrupted the way a large `upload_media_inline` base64 blob can (ESPECIALLY use this, not inline, for SVG/logos). For a public source URL, `upload_media_from_url` is the one-call equivalent; reserve `upload_media_inline` for tiny payloads only.',
|
|
63
63
|
inputSchema: {
|
|
64
64
|
filename: z.string().min(1),
|
|
65
65
|
content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
|
|
@@ -73,7 +73,7 @@ export const mediaTools = [
|
|
|
73
73
|
},
|
|
74
74
|
{
|
|
75
75
|
name: 'upload_media_from_url',
|
|
76
|
-
description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The bytes pass through the agent\'s machine — they do NOT go through the Typeroll API — so this works whenever your agent can `fetch()` the source. Returns { media_id, cdn_url, filename }.',
|
|
76
|
+
description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The bytes pass through the agent\'s machine — they do NOT go through the Typeroll API — so this works whenever your agent can `fetch()` the source. Integrity-safe: the bytes are streamed, never transcribed as base64 through the model, so (unlike a large `upload_media_inline` payload) they can\'t be silently corrupted in transit. Returns { media_id, cdn_url, filename }.',
|
|
77
77
|
inputSchema: {
|
|
78
78
|
source_url: z.string().url().describe('Public URL to download the image from.'),
|
|
79
79
|
filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url.'),
|
|
@@ -134,7 +134,7 @@ export const mediaTools = [
|
|
|
134
134
|
},
|
|
135
135
|
{
|
|
136
136
|
name: 'upload_media_inline',
|
|
137
|
-
description: 'Upload an image (or PDF)
|
|
137
|
+
description: 'Upload an image (or PDF) from base64-encoded bytes. ⚠️ Use ONLY for tiny payloads (a few KB). The base64 string crosses the model/tool boundary as text, where a larger blob can be SILENTLY CORRUPTED in transit — a few mutated chars yield a broken-but-valid-looking file that uploads with no error and the right size, and only fails when rendered (this has bitten a ~12KB logo SVG: half the wordmark vanished). For anything non-trivial, and ALWAYS for SVG/logos or assets you generated, prefer create_upload_url + a direct curl --data-binary PUT of the file bytes (bytes never transcribed → verified byte-identical), or upload_media_from_url (bytes fetched from a URL). After uploading any generated asset, VERIFY it (render it, or byte-diff against the source) before referencing it. Returns { media_id, cdn_url, filename }.',
|
|
138
138
|
inputSchema: {
|
|
139
139
|
filename: z.string().min(1),
|
|
140
140
|
content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
|
package/dist/tools/settings.js
CHANGED
|
@@ -6,13 +6,19 @@
|
|
|
6
6
|
// doesn't expose these fields, so conversation-driven assistants can't
|
|
7
7
|
// smuggle scripts in.
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
-
import { ok, withErrorBoundary } from './helpers.js';
|
|
9
|
+
import { ok, withErrorBoundary, versionParam } from './helpers.js';
|
|
10
|
+
function v(version) {
|
|
11
|
+
return version ? { version } : undefined;
|
|
12
|
+
}
|
|
10
13
|
export const settingsTools = [
|
|
11
14
|
{
|
|
12
15
|
name: 'read_site_settings',
|
|
13
|
-
description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, default meta description, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css.",
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
description: "Read every site setting: name, tagline, logo, favicon, colors, fonts, contact info, social links, default SEO suffix, default meta description, language, robots_txt, image_sizes_default, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css. Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
|
|
17
|
+
inputSchema: {
|
|
18
|
+
version: versionParam,
|
|
19
|
+
},
|
|
20
|
+
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
21
|
+
const res = await client.get(siteId, 'settings', v(args.version));
|
|
16
22
|
return ok(res);
|
|
17
23
|
}),
|
|
18
24
|
},
|
|
@@ -23,8 +29,10 @@ export const settingsTools = [
|
|
|
23
29
|
'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
|
|
24
30
|
'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
|
|
25
31
|
'Unknown top-level keys return a 400 error listing the valid fields. ' +
|
|
26
|
-
'scripts_head / scripts_body_end / custom_css ARE writable here — useful for a global stylesheet across all pages.'
|
|
32
|
+
'scripts_head / scripts_body_end / custom_css ARE writable here — useful for a global stylesheet across all pages. ' +
|
|
33
|
+
'Pass `version` to scope the write to a branch (copy-on-write) instead of main — the right way to brand/recolor a site inside a redesign branch (colors, fonts, logo, custom_css) without touching the live settings. Omit it to write main.',
|
|
27
34
|
inputSchema: {
|
|
35
|
+
version: versionParam,
|
|
28
36
|
site_name: z.string().optional(),
|
|
29
37
|
tagline: z.string().optional(),
|
|
30
38
|
logo: z.string().optional(),
|
|
@@ -65,7 +73,8 @@ export const settingsTools = [
|
|
|
65
73
|
social: z.record(z.string()).optional(),
|
|
66
74
|
},
|
|
67
75
|
handler: withErrorBoundary(async (args, { client, siteId }) => {
|
|
68
|
-
const
|
|
76
|
+
const { version, ...body } = args;
|
|
77
|
+
const res = await client.patch(siteId, 'settings', body, v(version));
|
|
69
78
|
return ok(res);
|
|
70
79
|
}),
|
|
71
80
|
},
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typeroll/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/skills/tr-brand.md
CHANGED
|
@@ -108,6 +108,14 @@ update_site_settings {
|
|
|
108
108
|
|
|
109
109
|
Read back to confirm: `read_site_settings`.
|
|
110
110
|
|
|
111
|
+
**Inside a redesign branch, scope the brand to the branch** — pass
|
|
112
|
+
`version="<branch>"` on `update_site_settings` (and `read_site_settings`).
|
|
113
|
+
Colors / fonts / logo / custom_css then live on the branch (copy-on-write,
|
|
114
|
+
chain-fallback to main for anything you don't override) and don't touch the
|
|
115
|
+
live site until you `merge_branch`. This is the correct way to rebrand on a
|
|
116
|
+
branch — don't hack the palette into a `:root{}` override in the header
|
|
117
|
+
partial just to keep it off live; that's no longer necessary.
|
|
118
|
+
|
|
111
119
|
### Site icons — always propose them, never leave them empty
|
|
112
120
|
|
|
113
121
|
Every site gets a favicon + apple touch icon as part of brand setup:
|