@typeroll/mcp-server 0.45.81 → 0.45.82

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.
@@ -9,7 +9,7 @@ export const BUNDLED_SKILLS = {
9
9
  "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\nConfirm the organization, site and version first. Personal OAuth calls require\nthe `site_context` returned by `select_site`. Read `read_site_instructions`\nand the selected site's enabled App guidance before changing content.\n\nResearch and migration seeding are not editor confirmations. On Core 0.2.95 /\nMCP 0.45.74+, create structured research Pages with `write_mode: \"import\"` and\nexplicit schema `writable_by: [\"import\", ...]`. For existing answers, discover\n`read_page_authority` and `import_page_fields`: read the revision, then change\nonly named leaves using stable keyed-item paths. This import saves immediately;\nthe ordinary draft-writing recipe below still applies to editorial writes.\nNever interpret absent provenance in `read_page` as an unprotected value, reset\nan owner's source, or retry a 409 using editor authority. Existing API-attributed\ndata requires separate review; it is not automatically downgraded.\n\n\n> **The buffer model (draft writes).** Every content write in this recipe\n> (pages, blocks and partials) lands in an unsaved per-doc\n> DRAFT — deploys and plain previews only see SAVED content. For recipe-style\n> build work, pass `save: true` on write calls (the work is pre-approved by\n> the task itself), or run `commit_working_copy` per doc before any\n> `trigger_deploy`. Preview your drafts with `include_working_copy: true`.\n\n\nThe default failure mode for an AI writing a page is \"good generic\nHTML in the wrong voice.\" This skill makes the discovery step\nnon-optional.\n\n## Recipe\n\n### 1. Always discover first\n\n```\nget_site # site name (use it in copy)\nread_site_settings # tagline, contact info, brand colors\nread_partial partial_id=\"header\" # what other pages exist in the nav\nlist_pages limit=5\nbatch_read_pages page_ids=[<2-3 representative pages>]\n```\n\nRead the actual HTML of an existing page. Note:\n- Heading structure (single `<h1>` per page? subtitle pattern?)\n- Whether the site uses CSS variables (`var(--color-primary)`) or\n hardcoded values\n- Tone (sober, playful, technical, marketing-y)\n- Length conventions (do existing pages run 200 words or 2000?)\n- Whether internal links use absolute or relative URLs\n\n`list_styles` too: use the site's named styles (`style_id` on heading, text,\nlist, button and section blocks) for eyebrows, leads, quotes and buttons. If a\ntreatment you need could recur and no style fits, create one rather than\nsetting one-off values; ask first when it changes the site's look.\n\nA small label above a heading is the heading block's `eyebrow`, and a line\nbelow it is its `subtitle` (render version 2), each with its own style field.\nNever write them as separate text blocks with classed HTML. In text blocks,\nstyle whole paragraphs with site text styles (`<p class=\"s-lead\">`). Content\nthat needs headings with parts, images or buttons belongs in their own blocks,\nnot in a text block's HTML.\n\n`list_block_templates` and `list_partials` too. Start a common section from a\nblock template (`insert_block_template`, then edit the copy). Use a global\nblock (`core/global_block`) for content that must stay identical on every\npage, such as a shared call to action; don't paste copies of it.\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## Instead of the content workflows\n\n`start_workflow` types `content_generation` and `content_improvement` run the\nmodel with the Organization's own AI key. On a Site without that access they\nfail with `failure_code: \"ai_not_enabled\"`; don't retry them. Do the work with\nthis recipe and tell the user you did it yourself:\n\n- **Generate a page** (`content_generation { topic, tone?, audience? }`):\n steps 1–5 for that topic, created as a draft.\n- **Improve content** (`content_improvement { limit? }`): `list_pages\n status=\"published\"` and `batch_read_pages`, rank the pages by thin text,\n missing `seo_title`/`seo_description` and age (not updated for a year), and\n rewrite the weakest few (five by default) in the site's voice. Keep facts,\n URLs and structure; leave the rewrites as unsaved drafts and show previews\n before anything is saved or published.\n\n## SEO conventions worth knowing\n\n- **`kind: \"article\"`** for blog posts and news. Switches to\n `og:type=article` + emits Article JSON-LD. Set `author` too — empty\n author = no Person schema = no author rich-result eligibility.\n- **SEO title** target 50-60 chars. Past 60 Google truncates.\n- **Meta description** target 150-160 chars. Don't write fluff to\n fill it; Google rewrites descriptions when they go off-topic.\n- **OG image** per page matters for shareable content. For articles\n especially.\n\n## Pitfalls\n\n- Reading 0 pages and just inventing a design is the most common\n failure. Always sample at least one existing page first.\n- Skipping the brief and producing 1000 words of plausible filler when\n the user wanted a 200-word landing. Ask up front.\n- Auto-publishing. Don't.\n",
10
10
  "tr-custom-blocks": "---\nname: tr-custom-blocks\ndescription: Use when a Typeroll site needs a block the core library does not have (an icon list, cards, steps, a team strip), so the user wants a reusable block type with its own fields.\n---\n\n# Build a site block type\n\nConfirm the organization, site and version first. Personal OAuth calls require\nthe `site_context` returned by `select_site`. Read `read_site_instructions`\nand the selected site's enabled App guidance before changing content.\n\n\nA site block type is reusable: editors place it and fill in its fields, and\nevery instance updates when the type changes. Build one when the same section\nshape recurs, or when editors should fill in fields rather than arrange\nblocks. For a one-off section use plain blocks; for a section copied and then\nadapted per page use a block template; for content that must be identical\neverywhere use a global block.\n\nCreating, changing and deleting block types needs write permission. A changed\nblock type reaches the live site with the next deploy.\n\n## 1. Look before you build\n\n```\nlist_block_types # core and site types: maybe it exists\nlist_block_type_starters # icon_list, feature_cards, steps\nlist_styles # the site's named styles to use inside\n```\n\nPrefer a starter or a core block with the right settings over a new type.\n\n## 2. Compose it from existing blocks\n\nComposed is the default. Declare the editable fields in `schema` and build the\nmarkup from core blocks in `composition`:\n\n- An inner block field set to exactly `\"{{props.name}}\"` takes the block's\n field value. Bindings keep their type (lists, links, booleans).\n- For a repeating group, add a `core/repeater` with\n `items: \"{{props.items}}\"`, `source_type: \"static\"` and no `item_block`. Its\n children render once per item and read the item with `\"{{item.title}}\"`.\n- Make a whole item a link with a `core/container`\n `{ tag: \"a\", href: \"{{item.link.href}}\", new_tab: \"{{item.link.new_tab}}\" }`.\n Items without a link render as a plain container.\n- Use `core/text` for values from text fields (always escaped), `core/heading`\n for headings, `core/icon`, `core/image`, `core/button`.\n- Give inner blocks named styles (`style_id`) instead of CSS where you can.\n\nField types: `text`, `textarea`, `richtext`, `image`, `icon` (`{name}_svg`),\n`link` (`{ page_id }` or `{ url }` plus `new_tab`; resolves to `href`, `target`,\n`rel` and follows page renames), `select` with `options`, `boolean`, `number`,\n`array` (`fields`, `item_label`, `min_items`, `max_items`), `object`.\n\n## 3. Check and preview before saving\n\n```\nvalidate_block_type definition={…} # problems with paths, e.g. /schema/0/fields/1/name\npreview_block_type definition={…} # html + css with sample data, as published\n```\n\nFix every error. Read warnings: a binding inside other text\n(`\"Call {{props.phone}}\"`) is shown as typed; only whole-value bindings resolve.\n\n## 4. CSS\n\n`styles` apply only inside the block: selectors are prefixed automatically,\n`:scope` is the block element, and `html`, `body` and `:root` are refused. Use\n`var(--color-primary)` and the site's spacing, not fixed colours, and keep text\nat WCAG AA contrast.\n\n## 5. Create, place, deploy\n\n```\ncreate_block_type name=\"icon_list\" label=\"Icon list\" category=\"content\" schema=[…] composition=[…] styles=\"…\"\nadd_block page_id=<id> block={ type: \"icon_list\", data: { items: [ … ] } }\nget_preview_link page_id=<id>\n```\n\nShow the preview to the user. Deploy only when they approve.\n\n## Template block types (advanced)\n\nWhen the markup cannot be built from blocks, use `template` instead of\n`composition`: `{{field}}` (escaped), `{{{richtext_field}}}`, `{{#field}}…{{/field}}`,\n`{{^field}}…{{/field}}` (empty), `{{#each items}}…{{/each}}` with `{{@index}}`,\n`{{@number}}`, `{{@first}}`, `{{@last}}`, and `{{#link field class=\"x\"}}…{{/link}}`.\nA type has a composition or a template, never both.\n\nDeclare `parts` for the elements of the markup a site may want to restyle:\n`parts: [{ name: \"title\", label: \"Title\", selector: \".icon-list-title\", target: \"heading\" }]`.\nSelectors are tags, classes, attributes and structural pseudo-classes, never\n`:hover` or `::before`. Styles then reach the part site-wide\n(`parts: [\"icon_list.title\"]` on the style) or on one block\n(`data.part_styles: { title: \"card-title\" }`, a field added automatically).\n\n## Changing a type in use\n\n- Rename a field with `renames: { \"old\": \"new\" }` (`\"items.old\": \"new\"` inside a\n group): Typeroll moves the data everywhere the type is used.\n- Removing or retyping a field that holds data is refused with the list of\n usages unless you send `confirm_data_loss: true`. Ask the user first.\n- `find_pages_using_block_type` before deleting; a type in use cannot be\n deleted.\n\n## Pitfalls\n\n- Pasting HTML into a `core/html` block inside a composition: editors cannot\n edit it and it skips the site's styles. Compose from real blocks.\n- Binding a text field into `core/prose`: the value becomes HTML. Use `core/text`.\n- Nesting links: an icon or button with its own link inside a linked container.\n- Linked containers on a site below render version 4: every icon sits in a\n link of its own, so the icon list starter nests links, and an item without\n an address is still a link. The write warns; upgrade the site's rendering\n (`tr-upgrade-rendering`) first.\n- Writing `script` without the user's explicit request.\n",
11
11
  "tr-design-review": "---\nname: tr-design-review\ndescription: Use to review a deployed/previewed Typeroll page like a designer — a MEASURED multi-dimension pass (responsive, a11y, functional, content, SEO, performance) that emits a per-dimension scorecard and an explicit OK verdict. Run it before telling the user a design is approved; it's the \"how\" for tr-redesign-branch's approval round.\n---\n\n# Review a design — measured, not glanced\n\nA design review is a MEASUREMENT, not a look. The failure mode is reporting\n\"looks good\" off a couple of screenshots — which silently misses overflow at\nuntested widths, sub-AA contrast, broken/blank images, and small touch targets.\nThis skill is the deterministic routine: per dimension, a check you RUN (a\nbrowser-eval snippet or a curl), and a scorecard you fill with PASS / FAIL /\nUNTESTED. Never report \"approved\" off a partial pass — list what you didn't test\nas caveats.\n\n`tr-redesign-branch` step 6 lists the dimensions (the \"what\"). This is the \"how\".\n\n## Cardinal rule: a screenshot is evidence, not proof\n\n**Full-page screenshots lie about lazy-loaded images.** A page with\n`loading=\"lazy\"` images below the fold will screenshot with BLANK boxes where\nthose images sit — they hadn't entered the viewport when the capture fired. If\nyou trust that, you will report a non-existent \"empty illustration box\" gap.\n(This has happened — on a real review, across three variants at once.)\n\nSo, always:\n\n- **Before any full-page capture**, scroll the whole page to trigger lazy loads\n and let it settle (snippet in §5), THEN screenshot.\n- **Verify every suspected blank/broken image via the DOM** (`naturalWidth` after\n scroll), never from the screenshot. A real broken image has `complete === true\n && naturalWidth === 0`; a lazy one that just hasn't loaded has `complete ===\n false` — scroll it into view and re-check before calling it broken.\n\n## Setup\n\n1. Get a URL for the version under review. While iterating, use the DB-live\n `get_preview_link` (mint once, reuse — defaults to a 24h TTL) — it renders\n from the DB with no build, so fixes show on reload without re-deploying, and\n it's the loop for the review-fix-recheck cycle. For a FINAL bit-for-bit check\n of the compiled output before merge, deploy once (`trigger_deploy\n version=\"<branch>\"` → poll `get_deploy_status` → use the immutable\n `deploy_url`, a Cloudflare Pages hash URL). Review the SAME url end to end.\n2. The snippets below run in a browser tool's \"evaluate JavaScript\" (Playwright /\n chrome-devtools / puppeteer MCP). **One origin per eval:** the iframe trick\n needs same-origin, so run each variant's snippet on its own page (different\n `*.pages.dev` hashes are cross-origin → `contentDocument` is null).\n3. If you run several variants with one shared browser profile, do them\n SEQUENTIALLY — parallel browser agents on one profile contaminate each other's\n tabs/screenshots.\n\n## The dimensions — run each, record the result\n\n### 1. Responsive — width ladder 390/768/1024/1440/1920, zero overflow\n\nMeasure horizontal overflow at every width in ONE eval using same-origin iframes\n(each iframe is its own layout viewport, so `@media` fires correctly — no 15\nresizes):\n\n```js\nasync () => {\n const url = location.href, out = [];\n for (const w of [390,768,1024,1440,1920]) {\n const f = document.createElement('iframe');\n f.style.cssText = `width:${w}px;height:2400px;border:0;position:fixed;left:-99999px;top:0`;\n document.body.appendChild(f);\n await new Promise(r => { f.onload = r; f.src = url; });\n await new Promise(r => setTimeout(r, 700));\n const d = f.contentDocument, culprits = [];\n for (const el of d.body.querySelectorAll('*')) {\n const r = el.getBoundingClientRect();\n if (r.right > w + 1 && r.width <= w + 40 && r.width > 4)\n culprits.push(el.tagName.toLowerCase() + '.' + (el.className||'').toString().slice(0,40));\n }\n out.push({ w, hOverflow: d.documentElement.scrollWidth - w, n: culprits.length, sample: [...new Set(culprits)].slice(0,6) });\n f.remove();\n }\n return out;\n}\n```\n\nPASS = `hOverflow <= 0` at every width. A decorative element flagged while\n`hOverflow` is 0 is clipped by an `overflow:hidden` parent (no scrollbar) — a\nnon-issue. Then eyeball one tablet (768) capture for stacking — but capture\nAFTER the scroll-settle in §5.\n\n### 2. Accessibility — compute contrast, don't eyeball\n\n```js\n() => {\n const L = c => { const a = c.map(v => (v/=255, v<=.03928?v/12.92:((v+.055)/1.055)**2.4)); return .2126*a[0]+.7152*a[1]+.0722*a[2]; };\n const P = c => { const m = c.match(/rgba?\\(([^)]+)\\)/); if(!m) return null; const p = m[1].split(',').map(parseFloat); return {rgb:[p[0],p[1],p[2]], a:p[3]??1}; };\n const R = (f,b) => { const x=L(f),y=L(b),h=Math.max(x,y),l=Math.min(x,y); return (h+.05)/(l+.05); };\n const bg = el => { let e=el; while(e){ const s=getComputedStyle(e); if(s.backgroundImage!=='none') return {img:1}; const c=P(s.backgroundColor); if(c&&c.a>.5) return {rgb:c.rgb}; e=e.parentElement; } return {rgb:[255,255,255]}; };\n const bad=[], seen=new Set();\n for (const el of document.body.querySelectorAll('*')) {\n const t=[...el.childNodes].filter(n=>n.nodeType===3&&n.textContent.trim()).map(n=>n.textContent.trim()).join(' ');\n if(!t) continue;\n const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;\n const s=getComputedStyle(el); if(s.visibility==='hidden'||s.display==='none'||+s.opacity<.1) continue;\n const fg=P(s.color); if(!fg) continue;\n const b=bg(el); if(b.img) continue; // can't compute over an image — eyeball hero text separately\n const cr=R(fg.rgb,b.rgb), fs=parseFloat(s.fontSize), fw=+s.fontWeight||400;\n const need = (fs>=24||(fs>=18.66&&fw>=700)) ? 3 : 4.5;\n if (cr<need) { const k=t.slice(0,30)+cr.toFixed(2); if(seen.has(k))continue; seen.add(k);\n bad.push({txt:t.slice(0,45), ratio:+cr.toFixed(2), need, fs:Math.round(fs), fw, color:s.color, bg:'rgb('+b.rgb.join(',')+')'}); }\n }\n return { failures: bad.length, items: bad.slice(0,15) };\n}\n```\n\nPASS = 0 failures (AA: body ≥4.5:1, large/UI ≥3:1). Fix a failure by deepening\nthe offending colour token. Text over an image background is skipped — eyeball\nthose (hero overlays) for legibility separately.\n\nStructure + alt + landmarks, same eval session:\n\n```js\n() => {\n const h=[...document.querySelectorAll('h1,h2,h3,h4')].map(e=>+e.tagName[1]);\n const skips=h.map((v,i)=>i&&v-h[i-1]>1?`${h[i-1]}->${v}`:0).filter(Boolean);\n const imgs=[...document.querySelectorAll('img')];\n const inputs=[...document.querySelectorAll('input:not([type=hidden]),textarea,select')];\n const labelFor=new Set([...document.querySelectorAll('label[for]')].map(l=>l.getAttribute('for')));\n return {\n h1: h.filter(x=>x===1).length, levelSkips: skips,\n imgsMissingAlt: imgs.filter(i=>i.getAttribute('alt')===null).length,\n landmarks: ['header','nav','main','footer'].filter(t=>document.querySelector(t)),\n unlabeledInputs: inputs.filter(i=>!(i.id&&labelFor.has(i.id))&&!i.getAttribute('aria-label')).map(i=>i.name||i.id),\n };\n}\n```\n\nPASS = exactly one `h1`, `levelSkips` empty, `imgsMissingAlt` 0, all four\nlandmarks present, `unlabeledInputs` empty. (Decorative images SHOULD have\n`alt=\"\"` — that's not \"missing\".)\n\nTouch targets — interactive elements ≥44px at mobile. Run in a 390px iframe;\nEXCLUDE `aria-hidden` (the form honeypot is a visible-sized but hidden input —\ncounting it is a false positive) and inline text links inside `p`/`li`:\n\n```js\nasync () => {\n const f=document.createElement('iframe');\n f.style.cssText='width:390px;height:2400px;border:0;position:fixed;left:-99999px;top:0';\n document.body.appendChild(f);\n await new Promise(r=>{ f.onload=r; setTimeout(r,3000); f.src=location.href; });\n await new Promise(r=>setTimeout(r,700));\n const d=f.contentDocument, small=[];\n if(d) for (const el of d.querySelectorAll('a,button,input:not([type=hidden]),textarea,select,[role=button]')) {\n const r=el.getBoundingClientRect(); if(r.width<2||r.height<2) continue;\n const s=getComputedStyle(el); if(s.display==='none'||s.visibility==='hidden'||+s.opacity<.1) continue;\n if(el.getAttribute('aria-hidden')==='true') continue;\n if(el.tagName==='A'&&el.closest('p,li')) continue;\n if(r.height<44||r.width<44) small.push({tag:el.tagName.toLowerCase(), txt:(el.innerText||el.value||el.getAttribute('aria-label')||'').trim().slice(0,24), w:Math.round(r.width), h:Math.round(r.height)});\n }\n f.remove(); return { undersized: small };\n}\n```\n\nAlso confirm `:focus-visible` and `prefers-reduced-motion` exist (grep the page\nHTML: `grep -c 'focus-visible' page.html`, `grep -c 'prefers-reduced-motion'`).\nNote honestly: presence in CSS ≠ verified per-element — tab through live if you\nclaim keyboard focus works.\n\n### 3. Functional — console, links, form\n\n- **Console:** read the browser tool's console messages after load. PASS = 0\n errors/warnings.\n- **Links:** PASS = no `href=\"#\"`/empty; every in-page `#anchor` has a matching\n `id`.\n- **Form (markup — does NOT prove a live submit):** curl the page and verify the\n `<form>` `action` is the real submit endpoint, the hidden `_token` is\n non-empty, the honeypot is present + `aria-hidden`, required fields have\n `required`, the email field is `type=\"email\"`. State explicitly that you did\n NOT submit (a live POST creates a real submission) unless you actually did.\n\n### 4. Content — verbatim, no placeholders\n\n`grep -Ei 'lorem|ipsum|\\{\\{|placeholder|TODO|FIXME' page.html` → 0. Copy matches\nthe live page (the source of truth) verbatim.\n\n### 5. Broken / blank images (the anti-lazy-load check — run THIS before trusting any screenshot)\n\n```js\nasync () => {\n const H=document.body.scrollHeight;\n for(let y=0;y<=H;y+=400){ window.scrollTo(0,y); await new Promise(r=>setTimeout(r,120)); }\n window.scrollTo(0,0); await new Promise(r=>setTimeout(r,1500));\n const imgs=[...document.querySelectorAll('img')];\n const empty=[]; // genuinely empty boxes: large, no text/img/svg/bg-image\n for (const el of document.querySelectorAll('div,section,figure')) {\n const r=el.getBoundingClientRect(); if(r.width<160||r.height<140) continue;\n if((el.innerText||'').trim()||el.querySelector('img,svg,picture,canvas,video')) continue;\n if(getComputedStyle(el).backgroundImage!=='none') continue;\n empty.push({cls:(el.className||'').toString().slice(0,36), w:Math.round(r.width), h:Math.round(r.height)});\n }\n return {\n broken: imgs.filter(i=>i.complete&&i.naturalWidth===0).map(i=>i.src.slice(-45)), // real failures\n stillLoading: imgs.filter(i=>!i.complete).map(i=>i.src.slice(-45)), // lazy, scroll first\n emptyBoxes: empty.slice(0,8), // true placeholders\n };\n}\n```\n\nPASS = `broken` empty, `emptyBoxes` empty. A non-empty `emptyBoxes` is a genuine\nunfilled illustration slot (fill it — pages shouldn't be text deserts). NOW\ncapture screenshots (the page is scrolled-and-settled, images loaded).\n\n### 5b. Clipped artwork — the logo (and any brand image) cut off by its own frame\n\nThe single most-repeated visual bug: the header logo rendered with its top/edges\nsliced. It produces ZERO page overflow (§1 misses it), the image isn't broken\n(§5 misses it), and at full-page screenshot scale a few clipped pixels are easy\nto glance past. So MEASURE it: does the artwork's rendered content touch the edge\nof its own box on any side? Content flush against the frame (gap ≈ 0) = clipped\nor about-to-clip. Don't just check the logo — check it, then trust the number.\n\nFor a raster/`<img>` logo, draw it to a same-origin canvas and scan the border\nrows/cols for opaque pixels (cross-origin taints the canvas — fetch the asset to\na localhost file first, as in §setup, or measure on the asset directly):\n\n```js\nasync (url) => { // url = the logo's currentSrc, served same-origin\n const img = new Image(); await new Promise((r,e)=>{img.onload=r;img.onerror=e;img.src=url;});\n const h = 64, w = Math.round(h*img.naturalWidth/img.naturalHeight);\n const c = document.createElement('canvas'); c.width=w; c.height=h;\n const x = c.getContext('2d'); x.drawImage(img,0,0,w,h);\n const d = x.getImageData(0,0,w,h).data, op=(px)=>d[px*4+3]>20;\n let top=h,bot=0,left=w,right=0;\n for(let y=0;y<h;y++)for(let xx=0;xx<w;xx++)if(op(y*w+xx)){top=Math.min(top,y);bot=Math.max(bot,y);left=Math.min(left,xx);right=Math.max(right,xx);}\n return { topGap:top, bottomGap:h-1-bot, leftGap:left, rightGap:right }; // any 0 → flush/clipped\n}\n```\n\nPASS = every gap ≥ ~2% of the dimension. A `0` on any side means the artwork (or\nits stroke) sits on the frame — for an SVG that's a viewBox trimmed flush to the\nart (look for `-trim`/`-tight` in the filename); the fix is to re-export the SVG\nwith viewBox padding (e.g. widen `viewBox` by ~8% each side) so the stroke never\ntouches the edge. Verify the fix by re-running this with the patched asset. (A\nheavy `stroke-width` + `paint-order=\"stroke\"` outline makes a flush viewBox clip\nvisibly — and small header renders make it worse, so also check the logo isn't\nshrunk below ~64–72px in the header.)\n\nAlso confirm no ANCESTOR clips the logo: walk the logo's parents for\n`overflow:hidden|clip` combined with a fixed height or negative/overlap margin —\nand always judge the logo from a screenshot of the header REGION in context,\nnever the logo element in isolation (an element screenshot re-renders the full\nart and hides the clip).\n\n**The inverse bug — an image FLOATING inside its frame (don't blame the file).**\nA full-bleed illustration that renders with a margin of empty frame around it\nusually isn't a bad asset — it's CSS. In blocks-mode the site-template's global\n`:where(.page-content) img{ margin:1rem 0 }` (and a default `border-radius`)\nleaks onto any `<img>` you didn't reset, so a framed hero/figure gets a 1rem gap\ninside its frame and looks like it \"floats\". Before re-cropping or regenerating,\n**open the actual image file** (`curl` the `.avif`/`.png`) — if the motif fills\nthe file edge-to-edge, the float is CSS: set `margin:0` (and `border-radius:0`)\non the framed `<img>` (e.g. `.your-frame img{margin:0}` or a blanket\n`.your-scope img{margin:0}`). Measure it: the `<img>`'s `getBoundingClientRect`\nshould equal its frame's inner box (no gap). Only when the *file itself* has\nbuilt-in background margin (motif ≪ frame) is cropping the right fix.\n\n### 6. Findable (SEO/meta) — curl, fast\n\n`<title>` (≤60 chars) + meta description present + sensible; `og:title/description/image`;\n`canonical`; `favicon` + `apple-touch-icon`; `<html lang>`; branches must be\n`noindex`. One curl + greps covers it.\n\n### 7. Fast (performance)\n\nPASS signals: no render-blocking JS you didn't add; responsive variants\n(`srcset` + AVIF/WebP) so a 1024px asset isn't shipped to a 380px slot;\n`width`/`height` or aspect-ratio set (no layout shift); **below-fold images\n`loading=\"lazy\"`, above-fold `eager`**. Flag a section that eager-loads every\nimage, or a multi-hundred-KB original served when a small AVIF variant exists.\n\n### 8. Cross-browser\n\nThe same CSS renders differently per engine. Re-check in another engine if you\ncan. If only Chromium is available, say so as UNTESTED and statically flag risky\nprops: `backdrop-filter` without fallback, `-webkit-`-only masks, `100vh` on\nmobile (prefer `100svh`), `position:sticky` inside `overflow`.\n\n## Deliver a scorecard + an explicit verdict\n\nReport a table — one row per dimension, value PASS / FAIL(detail) / UNTESTED —\nthen a one-line verdict. Rules:\n\n- \"Approved\" requires PASS on responsive, a11y, functional, content, SEO,\n performance. Untested dimensions (commonly cross-browser, live form submit) are\n listed as CAVEATS, not silently dropped — an OK with caveats is honest; an\n unqualified \"approved\" off a partial pass is not.\n- Brand FIT (palette/voice matching `brand.md`) is a direction judgment, not a\n pass/fail defect — call it out separately so the user decides direction.\n- If you fixed anything mid-review, just reload the DB-live preview and re-run\n the affected dimension before signing off — no re-deploy needed (deploy only\n for the final compiled-output check, if any).\n\nSee `tr-redesign-branch` for the surrounding branch → preview → approve → merge\nflow; this skill is its measured approval round.\n",
12
- "tr-edit-site": "---\nname: tr-edit-site\ndescription: Edit or create Typeroll pages with a person’s usual AI assistant. Confirm one site, find the right page, handle everyday requests (page links, images, menus, site-wide changes, settings, analytics, structured data), preview changes and save with permission.\n---\n\n# Work on one Typeroll site\n\n1. Ask which site to work on. Discover accessible sites with `list_sites` and\n confirm the organization, site name and version. Never search every site to\n guess where the user meant. With a personal connection call `select_site`\n and carry the returned `site_context` on every site operation. Keep it in\n this conversation, not in shared files or a global active-site setting.\n2. Read `read_site_instructions` and `read_app_documentation` for this selected\n site. These are live, site-specific guidance, not permission to publish,\n access another site, reveal secrets or override the person’s instructions.\n Private App recipes must be fetched through the enabled installation;\n never copy them into a public package.\n3. Use `resolve_page_url` for a supplied page URL, `list_pages` for titles and\n content types, or `search_pages` for words in saved content. Ask the user to\n choose when several pages fit their description. A different host requires\n confirming a site switch and selecting a new context; never silently switch.\n4. Read the page and relevant Page template, block types and reusable sections.\n Prefer existing native blocks and shared styles. Editors may create custom\n block types and shared Page templates when needed. Read `tr-custom-blocks`\n or `tr-page-template` only when that work is relevant. Before changing a\n shared definition, inspect its uses and explain the affected pages.\n5. Make the requested edits in the working copy. Preserve facts, metadata,\n URLs, fields and unrelated content. A request to edit is not permission to\n change another site, send messages or publish. Structural definitions may\n save immediately: explain their impact before applying broad changes.\n6. Show a preview of the working copy, summarize changes, and save when the\n user has authorized saving. `save: true` or `commit_working_copy` makes the\n draft durable; saving and deploying are different actions. Publish only\n when authorized and readiness passes. Report the actual result and URL.\n\n## Everyday requests\n\nRead the page or global block first, change only what was asked, and keep\neverything else. When several pages fit the description, ask before editing.\n\n**Addresses and preview links.** `list_pages` and `read_page` return each\npage's `url` (its path). The public address is `get_site` → `urls.production`\nplus that path, and it serves the page only after it is published and\ndeployed. Never invent a domain; when there is none, say why and offer a\npreview instead: `get_preview_link page_id=… include_working_copy:true` shows\ndrafts and unpublished pages. Mint one link and reuse it while iterating.\n\n**Images the user provides.** Upload first: a URL with `upload_media_from_url`,\na local file with `create_upload_url` (see `tr-images`); `list_media` finds\nexisting ones. Use the returned CDN URL exactly. With a named target, place it\nthere (`add_block` with `core/image` on a block page; an `<img>` with `alt` in\n`html_content` on an HTML page). Without one, ask what the image is for. Preserve meaningful alt text or describe the image’s function; decorative images\nmay have empty alt, while linked images need an accessible name. Ask when the\nintended purpose is unclear. Afterwards say in one sentence where it\nlanded; to answer \"has it been added?\", read the page again rather than\ntrusting the conversation.\n\n**Small edits and new pages.** Block pages: `get_page_blocks`, then\n`update_block` on the one block. HTML pages: `read_page`, then `update_page`\nwith the complete `html_content`, never a fragment. New pages: `create_page`\n(draft by default) with a realistic start: heading, introduction and one\nsection. A Page template is `update_page` `template` (an allowed ID, or null\nfor the content type default). `discard_working_copy` throws an unsaved draft\naway. Publishing means saving the draft, `update_page` `status: \"published\"`\n(applies immediately) and, when the user wants it live now, `trigger_deploy`.\n\n**Site-wide changes are composition, not a series of page edits.** \"Change\nevery hero\", \"a CTA on every product page\", \"our phone number in every\nfooter\":\n\n1. `list_partials` shows what is already shared: header, footer and free\n global blocks.\n2. If a fitting global block exists, edit it once. Run\n `find_pages_using_block` first and tell the user the blast radius (\"this\n block is on 8 pages and one template — go ahead?\"); a header or footer\n among the results means every page.\n3. If nothing fits, propose a global block: `make_block_global` turns an\n existing block into one, or `create_free_block` with a kebab-case ID. Place\n it with `core/global_block` on block pages or `<x-include name=\"…\" />` on\n HTML pages. Layout that every Page of a type shares belongs in its Page\n template (`tr-page-template`).\n4. Edit pages one by one only for content that really differs per page, such\n as each page's SEO description. Confirm the page list first.\n\n\"All 23 pages\" or \"every page\" is a strong hint for a global block. Say what\nyou will do and wait for confirmation before editing many pages.\n\n**Extract while you sweep.** Even when the user asked for per-page edits,\npause when the same content is about to land on a third page and offer to\nmake it a global block instead (\"I'm adding the same CTA to several pages —\nmake it a reusable `newsletter-cta` block so future edits happen once?\").\nCheck `list_partials` and `find_pages_using_block` first so you extend an\nexisting block rather than create a duplicate.\n\n**Menu and footer.** `read_partial partial_id=\"header\"`. In a block header,\nchange the `core/navigation` block's `links` with `update_block`; in an HTML\nheader, add the `<a href=\"/pricing\">Pricing</a>` to the nav and write back the\ncomplete HTML with `update_partial`. The footer works the same way. Building\neither from scratch: `tr-header-footer`.\n\n**Settings, not page edits.** Brand colors, fonts, logo, icons and contact\ndetails are `update_site_settings` (admin), for example `colors.primary`. A new\nlogo must be uploaded first. Analytics, advertising pixels and chat widgets\nare set by ID in the Integrations app (`read_app app_id=\"integrations\"`, then\n`update_app`), not pasted as scripts; turn on the cookie banner\n(`update_site_settings` `cookie_consent: { enabled: true }`) so they wait for\nconsent. Only a tag the Integrations catalog lacks goes in\n`cookie_consent.scripts_optional`. Both reach the site with the next deploy.\n\n**Redirects, forms, listings.** A renamed URL gets `create_redirect` (301\nunless the user says otherwise); changing a page's `path` creates one on save.\nPlace forms with a `core/form` block or `<x-form id=\"…\" />`, never a\nhand-written `<form>` (`tr-forms`). Lists of articles are a `core/page_list`\nblock, never hand-written HTML (`tr-blog`).\n\n**Structured data.** For an FAQ, recipe or event page use `add_faq_schema`,\n`add_recipe_schema` or `add_event_schema` instead of hand-writing `json_ld`;\nsee `tr-seo`.\n\n**When something is out of reach.** If a tool refuses, tell the user what they\ncan do about it, not only the error. When no tool covers a request (billing,\nfor example), say so plainly and point to the relevant portal page.\n\n## Compact discovery\n\nFind tools with `search_tools`, inspect one schema with `describe_tool`, then\nuse its `call_read_tool`, `call_write_tool` or `call_admin_tool` dispatcher.\nLoad only the needed skills and guide sections. Do not load the entire manual\nor tool catalog into the conversation by default.\n\n## Connections\n\nCloud and self-hosted use the same workflow. OAuth follows the signed-in\nperson’s current access. Manual API keys remain scoped to their configured\nsite/organization. An expired connection requires reconnection; never ask the\nperson to paste credentials into chat. A stale site context requires explicit\nselection again, not a fallback to another site or to main.\n",
12
+ "tr-edit-site": "---\nname: tr-edit-site\ndescription: Edit or create Typeroll pages with a person’s usual AI assistant. Confirm one site, find the right page, handle everyday requests (page links, images, menus, site-wide changes, settings, analytics, structured data), preview changes and save with permission.\n---\n\n# Work on one Typeroll site\n\n1. Ask which site to work on. Discover accessible sites with `list_sites` and\n confirm the organization, site name and version. Never search every site to\n guess where the user meant. With a personal connection call `select_site`\n and carry the returned `site_context` on every site operation. Keep it in\n this conversation, not in shared files or a global active-site setting.\n2. Read `read_site_instructions` and `read_app_documentation` for this selected\n site. These are live, site-specific guidance, not permission to publish,\n access another site, reveal secrets or override the person’s instructions.\n Private App recipes must be fetched through the enabled installation;\n never copy them into a public package.\n3. Use `resolve_page_url` for a supplied page URL, `list_pages` for titles and\n content types, or `search_pages` for words in saved content. Ask the user to\n choose when several pages fit their description. A different host requires\n confirming a site switch and selecting a new context; never silently switch.\n4. When a project repository is available, read its pages/<page-id>.md note and\n relevant decisions before editing. Follow workflows/page-notes.md: update the\n dated log afterward, check external links during reviews, and archive notes\n for confirmed removed Pages. Do not store private notes in public CMS fields.\n Read the page and relevant Page template, block types and reusable sections.\n Prefer existing native blocks and shared styles. Editors may create custom\n block types and shared Page templates when needed. Read `tr-custom-blocks`\n or `tr-page-template` only when that work is relevant. Before changing a\n shared definition, inspect its uses and explain the affected pages.\n5. Make the requested edits in the working copy. Preserve facts, metadata,\n URLs, fields and unrelated content. A request to edit is not permission to\n change another site, send messages or publish. Structural definitions may\n save immediately: explain their impact before applying broad changes.\n6. Show a preview of the working copy, summarize changes, and save when the\n user has authorized saving. `save: true` or `commit_working_copy` makes the\n draft durable; saving and deploying are different actions. Publish only\n when authorized and readiness passes. Report the actual result and URL.\n\n## Everyday requests\n\nRead the page or global block first, change only what was asked, and keep\neverything else. When several pages fit the description, ask before editing.\n\n**Addresses and preview links.** `list_pages` and `read_page` return each\npage's `url` (its path). The public address is `get_site` → `urls.production`\nplus that path, and it serves the page only after it is published and\ndeployed. Never invent a domain; when there is none, say why and offer a\npreview instead: `get_preview_link page_id=… include_working_copy:true` shows\ndrafts and unpublished pages. Mint one link and reuse it while iterating.\n\n**Images the user provides.** Upload first: a URL with `upload_media_from_url`,\na local file with `create_upload_url` (see `tr-images`); `list_media` finds\nexisting ones. Use the returned CDN URL exactly. With a named target, place it\nthere (`add_block` with `core/image` on a block page; an `<img>` with `alt` in\n`html_content` on an HTML page). Without one, ask what the image is for. Preserve meaningful alt text or describe the image’s function; decorative images\nmay have empty alt, while linked images need an accessible name. Ask when the\nintended purpose is unclear. Afterwards say in one sentence where it\nlanded; to answer \"has it been added?\", read the page again rather than\ntrusting the conversation.\n\n**Small edits and new pages.** Block pages: `get_page_blocks`, then\n`update_block` on the one block. HTML pages: `read_page`, then `update_page`\nwith the complete `html_content`, never a fragment. New pages: `create_page`\n(draft by default) with a realistic start: heading, introduction and one\nsection. A Page template is `update_page` `template` (an allowed ID, or null\nfor the content type default). `discard_working_copy` throws an unsaved draft\naway. Publishing means saving the draft, `update_page` `status: \"published\"`\n(applies immediately) and, when the user wants it live now, `trigger_deploy`.\n\n**Site-wide changes are composition, not a series of page edits.** \"Change\nevery hero\", \"a CTA on every product page\", \"our phone number in every\nfooter\":\n\n1. `list_partials` shows what is already shared: header, footer and free\n global blocks.\n2. If a fitting global block exists, edit it once. Run\n `find_pages_using_block` first and tell the user the blast radius (\"this\n block is on 8 pages and one template — go ahead?\"); a header or footer\n among the results means every page.\n3. If nothing fits, propose a global block: `make_block_global` turns an\n existing block into one, or `create_free_block` with a kebab-case ID. Place\n it with `core/global_block` on block pages or `<x-include name=\"…\" />` on\n HTML pages. Layout that every Page of a type shares belongs in its Page\n template (`tr-page-template`).\n4. Edit pages one by one only for content that really differs per page, such\n as each page's SEO description. Confirm the page list first.\n\n\"All 23 pages\" or \"every page\" is a strong hint for a global block. Say what\nyou will do and wait for confirmation before editing many pages.\n\n**Extract while you sweep.** Even when the user asked for per-page edits,\npause when the same content is about to land on a third page and offer to\nmake it a global block instead (\"I'm adding the same CTA to several pages —\nmake it a reusable `newsletter-cta` block so future edits happen once?\").\nCheck `list_partials` and `find_pages_using_block` first so you extend an\nexisting block rather than create a duplicate.\n\n**Menu and footer.** `read_partial partial_id=\"header\"`. In a block header,\nchange the `core/navigation` block's `links` with `update_block`; in an HTML\nheader, add the `<a href=\"/pricing\">Pricing</a>` to the nav and write back the\ncomplete HTML with `update_partial`. The footer works the same way. Building\neither from scratch: `tr-header-footer`.\n\n**Settings, not page edits.** Brand colors, fonts, logo, icons and contact\ndetails are `update_site_settings` (admin), for example `colors.primary`. A new\nlogo must be uploaded first. Analytics, advertising pixels and chat widgets\nare set by ID in the Integrations app (`read_app app_id=\"integrations\"`, then\n`update_app`), not pasted as scripts; turn on the cookie banner\n(`update_site_settings` `cookie_consent: { enabled: true }`) so they wait for\nconsent. Only a tag the Integrations catalog lacks goes in\n`cookie_consent.scripts_optional`. Both reach the site with the next deploy.\n\n**Redirects, forms, listings.** A renamed URL gets `create_redirect` (301\nunless the user says otherwise); changing a page's `path` creates one on save.\nPlace forms with a `core/form` block or `<x-form id=\"…\" />`, never a\nhand-written `<form>` (`tr-forms`). Lists of articles are a `core/page_list`\nblock, never hand-written HTML (`tr-blog`).\n\n**Structured data.** For an FAQ, recipe or event page use `add_faq_schema`,\n`add_recipe_schema` or `add_event_schema` instead of hand-writing `json_ld`;\nsee `tr-seo`.\n\n**When something is out of reach.** If a tool refuses, tell the user what they\ncan do about it, not only the error. When no tool covers a request (billing,\nfor example), say so plainly and point to the relevant portal page.\n\n## Compact discovery\n\nFind tools with `search_tools`, inspect one schema with `describe_tool`, then\nuse its `call_read_tool`, `call_write_tool` or `call_admin_tool` dispatcher.\nLoad only the needed skills and guide sections. Do not load the entire manual\nor tool catalog into the conversation by default.\n\n## Connections\n\nCloud and self-hosted use the same workflow. OAuth follows the signed-in\nperson’s current access. Manual API keys remain scoped to their configured\nsite/organization. An expired connection requires reconnection; never ask the\nperson to paste credentials into chat. A stale site context requires explicit\nselection again, not a fallback to another site or to main.\n",
13
13
  "tr-forms": "---\nname: tr-forms\ndescription: Use when the user wants to add a contact form, newsletter signup, booking form, or other web form to a Typeroll site.\n---\n\n# Add a form to a Typeroll site\n\nTyperoll forms are server-backed. The platform renders the form shell, accepts\nsigned submissions, validates declared fields, stores submissions, and runs\nemail and webhook actions configured in the portal or through MCP/API.\n\n## Choose the placement\n\n- Block-mode page: add `{ type: 'core/form', data: { form_id } }`.\n- HTML-mode page: add `<x-form id=\"form-id\" />` to `html_content`.\n\nBoth are authoring references to the same renderer. Static generation expands\nthem to the complete form HTML, signed token, honeypot, initial step state,\nstyles, and shared runtime. Previews expand them the same way but in preview\nmode (no token, a \"Preview – nothing is sent\" notice). Never hand-write the\n`<form>` shell or paste a token into page HTML.\n\n## Create a simple form\n\n`fields[]` is authoring sugar for one static step:\n\n```json\n{\n \"id\": \"newsletter\",\n \"name\": \"Newsletter\",\n \"fields\": [\n {\"name\":\"email\", \"type\":\"email\", \"label\":\"E-postadress\", \"required\":true}\n ],\n \"submit_text\": \"Prenumerera\",\n \"success_message\": \"Tack! Du är anmäld.\"\n}\n```\n\n`success_message` may contain basic sanitized HTML, such as a link to the next\nstep. To send the visitor somewhere after the final step instead, set\n`success_redirect_url` to a root-relative path (`/tack/`) or an `http(s)` URL,\nfor example a booking page. Other schemes are rejected; `\"\"` clears it.\n\nField names must match `[a-z][a-z0-9_-]*`. Labels may be localized. Supported\nsimple types include `text`, `email`, `tel`, `url`, `number`, `textarea`,\n`select`, `radio`, `checkbox`, `hidden`, and `gdpr_consent`.\n\n## Place the form\n\nOn a block-mode page:\n\n```\nadd_block target={kind:'page', id:'start'}\n block={type:'core/form', data:{form_id:'newsletter'}}\n```\n\nOn an HTML-mode page:\n\n```html\n<section class=\"newsletter-signup\">\n <h2>Få våra nyheter</h2>\n <x-form id=\"newsletter\" />\n</section>\n```\n\n`<x-form>` is not a browser-side shortcode. It is replaced during server\npreview/build, so it also supports multi-step forms and never needs inline JS.\n\n## Multi-step form\n\nFor a funnel, write `steps[]` directly. Each step is a block tree containing\n`form/*` field blocks and optional content blocks:\n\n```\nupdate_form form_id=ansokan patch={steps:[\n {id:'company', title:'Företag', blocks:[\n {type:'form/text', data:{name:'company', label:'Företag', required:true}},\n {type:'form/email', data:{name:'email', label:'E-post', required:true}}\n ]},\n {id:'details', title:'Detaljer', blocks:[\n {type:'form/textarea', data:{name:'message', label:'Meddelande'}},\n {type:'form/consent', data:{name:'consent', text:'<p>Jag godkänner …</p>'}}\n ]}\n]}\n```\n\nSubmissions accumulate in one partial record and become complete on the final\nstep. Abandoned partials use `partial_ttl_days` (default 30).\n\nNavigation: a step may set `submit_label`; otherwise (render version 5+) every\nstep but the last reads \"Continue\"/\"Fortsätt\" and the last uses `submit_text`.\n`allow_back` (default on from render version 5) shows a Back button that keeps\nthe answers; `show_progress: true | \"text\" | \"bar\"` shows \"Step X of Y\" or a\nbar. Give a step a `title` or a leading `form/heading`, not both; the write\nreturns a `warnings` entry when a step has both.\n\n## Storage and integrations\n\nCompleted submissions appear in Forms → Submissions. Admins can configure\nactions in the form editor:\n\n- Email notification or autoresponder through the site's email connector.\n- Generic webhook to a public HTTPS endpoint. The admin chooses an explicit\n field allowlist and signing secret. Typeroll signs the exact request body in\n `X-Typeroll-Signature`, sends an idempotency key, retries transient failures,\n and stores delivery status.\n\nAgents manage the same actions with `read_form` (actions with secrets masked)\nand `update_form patch={ actions: [...] }`, with admin permission as in the\nportal. `actions` replaces the whole list: read the form, change what you need\nand send the list back. Examples:\n\n```\nupdate_form form_id=contact patch={ actions: [\n { type: \"email\", config: { to: \"hello@example.com\", subject: \"New lead: {{name}}\",\n body: \"<p>{{message}}</p>\", include_all: true, reply_to: \"{{email}}\" } },\n { type: \"email\", config: { to: \"{{email}}\", subject: \"Thanks, {{name}}\",\n body: \"<p>We will get back to you within a day.</p>\" } }\n] }\n```\n\nAn email action with `trigger: \"partial_abandoned\"` and `after_hours: 2` is\nsent once for a multi-step submission that stopped part-way and has not moved\non for two hours (never for completed ones) — opt-in follow-up on leads who\nleft after step 1.\n\nA webhook needs `url` (https), an explicit `fields` allowlist and a signing\n`secret`; send the masked value back to keep a stored secret.\n`get_form_capabilities` lists every action type the site offers, including\ntypes from installed apps, with the config fields each one takes.\n\nEmail actions send through the site's email provider. Check it with\n`get_email_settings`; if `email` is null, connect one with `set_email_settings`\n(for example `type: \"postmark\"`, `from`, `config: { server_token }`) and confirm\nwith `send_test_email`. Without a provider, submissions are stored but nobody is\nnotified. These tools need admin permission, like Settings → Email & notifications.\n\n`list_form_submissions` reads what visitors sent, `read_form_submission` reads\none entry (admins also see webhook delivery status) and `delete_form_submission`\nremoves one.\n\n## Verify\n\n1. `read_form form_id=\"newsletter\"` and confirm the steps/fields.\n2. Preview the page (`get_preview_link` or `get_page_preview`) and confirm the\n authoring reference has expanded to a form with `data-tr-form-el` and the\n platform runtime. Click through every step: previews validate, advance steps\n and show the success message (or name the redirect target) like the\n published form, marked \"Preview – nothing is sent\". They store nothing and\n run no actions, so keep the runtime in any review copy.\n3. Submit a real test entry on a deployed page and confirm it appears in\n Forms → Submissions.\n4. If a webhook is configured, confirm its delivery status\n (`read_form_submission`) and the receiving system's idempotency key before\n deploying.\n5. If an email action is configured, confirm `get_email_settings` shows a\n provider and the test message arrived.\n\n## Common patterns\n\nNewsletter:\n\n```json\n{\"fields\":[{\"name\":\"email\",\"type\":\"email\",\"label\":\"E-postadress\",\"required\":true}]}\n```\n\nContact:\n\n```json\n{\"fields\":[\n {\"name\":\"name\",\"type\":\"text\",\"label\":\"Namn\",\"required\":true},\n {\"name\":\"email\",\"type\":\"email\",\"label\":\"E-post\",\"required\":true},\n {\"name\":\"message\",\"type\":\"textarea\",\"label\":\"Meddelande\",\"required\":true}\n]}\n```\n\nBooking request:\n\n```json\n{\"fields\":[\n {\"name\":\"name\",\"type\":\"text\",\"label\":\"Namn\",\"required\":true},\n {\"name\":\"email\",\"type\":\"email\",\"label\":\"E-post\",\"required\":true},\n {\"name\":\"time\",\"type\":\"select\",\"label\":\"Tid\",\"options\":[\"09:00\",\"10:00\",\"14:00\"]},\n {\"name\":\"notes\",\"type\":\"textarea\",\"label\":\"Kommentar\"}\n]}\n```\n\nCampaign tracking: hidden fields named `utm_*` are filled from the same-named\nparameter in the page address (`?utm_source=linkedin`); `default` is sent when\nthe parameter is missing. Only the current address is read and nothing is\nstored in the browser, so no consent is needed; the campaign is not carried\nacross pages. Other hidden fields always send their `default`.\n\n```json\n{\"fields\":[\n {\"name\":\"email\",\"type\":\"email\",\"label\":\"E-post\",\"required\":true},\n {\"name\":\"utm_source\",\"type\":\"hidden\"},\n {\"name\":\"utm_medium\",\"type\":\"hidden\",\"default\":\"website\"},\n {\"name\":\"utm_campaign\",\"type\":\"hidden\"}\n]}\n```\n\n## Pitfalls\n\n- Do not hand-write a form, token, honeypot, or submit script.\n- Do not put a raw `<script>` in page HTML; the sanitizer removes it.\n- Do not drop existing actions by accident: `actions` replaces the list.\n- Do not send every submitted field to a webhook by default; choose the\n smallest allowlist the external register needs.\n- Built-in visitor messages (button defaults, validation and error messages,\n the no-JS confirmation page) follow the site's language, Swedish for `sv` and\n English otherwise. Do not add `error_messages` only to translate them.\n- `submit_token` is stable until the platform rotates its form-signing secret;\n a rebuild refreshes it after rotation.\n",
14
14
  "tr-header-footer": "---\nname: tr-header-footer\ndescription: Build accessible native header and footer partials with the site logo, semantic navigation, responsive disclosure, and optional native cookie consent.\n---\n\n# Native header and footer compositions\n\nUse block-mode partials for new work. Prefer `get_composition_starter`\nfor the current native header tree: `template/site_logo`, `core/navigation_menu`\nand `core/navigation_links`. Use the menu's dialog/drawer rather than an inline\nmobile expansion that moves the logo. Existing `core/navigation` remains supported.\n\n## Preconditions\n\n1. Read `get_site_capabilities`, `read_site_settings`, `list_partials`, and\n `list_block_types`.\n2. Require `supports_native_navigation`, `supports_partial_mode_switching`,\n `supports_responsive_data_fields`, and\n `supports_versioned_block_type_inheritance`.\n3. Read the existing header/footer before replacing anything. Work in a branch\n for redesigns and preview at desktop, 390 px, keyboard-only, and 200% zoom.\n\n## Header tree\n\nStage this tree with `update_partial partial_id=\"header\" patch={blocks:[...]}\nsave=true`, then call `set_partial_mode partial_id=\"header\" to=\"blocks\"` and\nread it back. Localize both accessible labels and every link label.\n\n```json\n[\n {\n \"type\": \"core/section\",\n \"data\": {\n \"width\": \"wide\",\n \"padding_y\": \"compact\"\n },\n \"children\": [\n {\n \"type\": \"core/container\",\n \"data\": {\n \"direction\": \"row\",\n \"wrap\": \"nowrap\",\n \"align_main\": \"space-between\",\n \"align_cross\": \"center\",\n \"width\": \"full\"\n },\n \"children\": [\n {\n \"type\": \"template/site_logo\",\n \"data\": {\n \"height\": \"md\",\n \"link_to_home\": true\n }\n },\n {\n \"type\": \"core/navigation_menu\",\n \"data\": {\n \"aria_label\": \"Main navigation\",\n \"menu_label\": \"Open menu\",\n \"close_label\": \"Close menu\",\n \"collapse_below\": \"1024\"\n },\n \"slots\": [\n [\n {\n \"type\": \"core/navigation_links\",\n \"data\": {\n \"direction\": \"row\",\n \"links\": [\n {\n \"label\": \"Home\",\n \"href\": \"/\"\n },\n {\n \"label\": \"About\",\n \"href\": \"/about/\"\n },\n {\n \"label\": \"Contact\",\n \"href\": \"/contact/\"\n }\n ]\n }\n }\n ],\n [\n {\n \"type\": \"core/navigation_links\",\n \"data\": {\n \"direction\": \"column\",\n \"links\": [\n {\n \"label\": \"Home\",\n \"href\": \"/\"\n },\n {\n \"label\": \"About\",\n \"href\": \"/about/\"\n },\n {\n \"label\": \"Contact\",\n \"href\": \"/contact/\"\n }\n ]\n }\n }\n ]\n ]\n }\n ]\n }\n ]\n }\n]\n```\n\nThe first slot is desktop; the second is the optional mobile composition.\nThe default is drawer below 1024 px and inline from 1024 px. For responsive\nlink fields use matching `responsive_breakpoints` (tablet 768, laptop 1024,\ndesktop 1280, wide 1536); do not independently hide all links at the menu boundary.\nVerify 1023/1024 px, Tab/Shift-Tab, Enter/Space, Escape returning focus,\nvisible focus, long labels, no-JavaScript links and no horizontal overflow.\n\n## Footer tree and consent\n\nUse an accessible footer navigation landmark and `core/navigation_links`, not\na second header drawer. If the footer has running about text, give it a separate,\nwider column beside a nested link grid. An equal four-column grid is rarely a\ngood fit for one dense paragraph and three short link lists. Start with\n`core/columns` ratio `1-2`, put flow containers inside the slots, and tune the\ngap and readable text width to the actual content. Stack on mobile. Inspect at\n390, 1024, 1280 and 1536 px; do not copy one site's pixel values blindly.\n\nCookie consent is a site feature, not a footer widget. Configure it separately:\n\n```json\n{\n \"cookie_consent\": {\n \"enabled\": true,\n \"text\": \"We use optional cookies. <a href=\\\"/privacy/\\\">Privacy policy</a>\",\n \"privacy_policy_url\": \"/privacy/\",\n \"reload_after_consent\": false\n }\n}\n```\n\nUse `update_site_settings`, then read settings back. `scripts_necessary` and\n`scripts_optional` execute in visitors' browsers and must be reviewed like\nother API-key-authorized script surfaces. Verify accept/necessary/reject,\noptional-script gating, and keyboard focus in preview and the actual build.\nThe signed hosted preview runs at an opaque origin. Consent choices therefore\nuse in-memory state for the current preview document; they are intentionally\nnot persisted as portal cookies across a reload or page navigation. Accepting\nall must still activate optional scripts immediately, which is enough to test\nthe gate without weakening the preview sandbox.\n\n## Version and dependency rules\n\nChild versions inherit installed, custom, and third-party block types from\ntheir base chain. If preview reports a missing type, stop and inspect\n`list_block_types` for that exact version; do not duplicate an Extension or\ninstallation. A type missing after capability 0.43.0 is a dependency error to\nreport, not a reason to let the block silently disappear.\n\n## Final checks\n\n- Header and footer landmarks exist exactly once around their content.\n- Logo is fully visible, keeps its aspect ratio, has useful alt text, and links home.\n- Current-page state and localized navigation labels are correct.\n- Mobile disclosure is keyboard accessible and does not trap focus.\n- No tenant CSS is required for spacing, focus, wrapping, or the narrow layout.\n- Preview and a hosted static build match before promotion.\n",
15
15
  "tr-imagegen": "---\nname: tr-imagegen\ndescription: Use when the user wants to generate images for a Typeroll site with AI models (Gemini, OpenAI, Higgsfield) — hero images, illustrations, section backgrounds, og-images. Triggers on \"generera bilder\", \"generate images\", \"skapa en hero-bild\", \"AI-bilder\", \"bildgenerering\", or when a brief calls for imagery that doesn't exist in assets/. Covers the local lab loop (generate → review → pick) and uploading winners to the Typeroll media library.\n---\n\n# Generate images for a Typeroll site (local lab → media library)\n\nImage generation runs **locally** in the site workdir — provider API keys\nlive in the folder's `.env`, candidates land in `images/lab/`, and only\nthe picked winners are uploaded to the Typeroll media library via the\nregular media tools (see `tr-images` for the upload/variants half).\n\nWhy local: you can look at the candidates (Read the files), iterate on\nprompts cheaply, and never ship a key or a reject anywhere.\n\n## Folder convention\n\n```\n<site>/\n├── .env # provider keys — GITIGNORED, never committed\n├── prompts/\n│ └── image-style.md # the site's image style profile (see below)\n└── images/\n └── lab/ # generated candidates — gitignored, disposable\n```\n\n`.env` keys (only the ones the user has — check before assuming):\n\n```\nGEMINI_API_KEY=...\nOPENAI_API_KEY=...\n```\n\n(Higgsfield needs no key here — it connects as an MCP server, see below.)\n\nLoad them per-command (`source .env` doesn't persist between Bash calls):\n\n```bash\nexport $(grep -v '^#' .env | xargs) # prepend to each generation command\n```\n\n## The style profile — prompts/image-style.md\n\nEvery site gets ONE style profile that you **prepend to every image\nprompt**. This is what keeps 20 images generated across 5 sessions\nlooking like one site. Derive it from `assets/brand.md` + the brief if\nit doesn't exist yet, and confirm it with the user before generating at\nscale. Keep it short (5–10 lines): art direction, palette, mood,\nphotography vs illustration, what to avoid.\n\nExample shape:\n\n```markdown\n# Bildstil — <Sajtnamn>\nVarm, folklig illustration med mjuka rundade former. Platt 2D med\nsubtila skuggor — ingen 3D, ingen fotorealism. Palett: kobolt #1F4FB8,\nsol #FFC83D, grädde #FFF8EC; accenter sparsamt. Människor: enkla,\ninkluderande, glada — inga karikatyrer. Undvik: stockfoto-känsla, text\ni bilden, logotyper, watermarks.\n```\n\n## Generate candidates\n\nName candidates descriptively: `images/lab/<motiv>-<modell>-<n>.png`.\nGenerate 3–6 candidates per slot (mix models when several keys exist),\nthen **Read the files to actually look at them** before showing the\nuser your shortlist.\n\n### Gemini (gemini-2.5-flash-image)\n\n```bash\ncurl -s \"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent\" \\\n -H \"x-goog-api-key: $GEMINI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"contents\": [{\"parts\": [{\"text\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\"}]}],\n \"generationConfig\": {\"imageConfig\": {\"aspectRatio\": \"16:9\"}}\n }' | jq -r '.candidates[0].content.parts[] | select(.inlineData) | .inlineData.data' \\\n | base64 -d > images/lab/hero-gemini-1.png\n```\n\nAspect ratios: `1:1`, `16:9`, `4:3`, `3:4`, `9:16`. Gemini also does\nimage *editing* — pass an existing image as an `inlineData` part plus an\ninstruction to restyle/extend it (useful for \"same illustration but\nwinter\").\n\n### OpenAI (gpt-image-1)\n\n```bash\ncurl -s https://api.openai.com/v1/images/generations \\\n -H \"Authorization: Bearer $OPENAI_API_KEY\" -H 'Content-Type: application/json' \\\n -d '{\n \"model\": \"gpt-image-1\",\n \"prompt\": \"<STYLE PROFILE>\\n\\n<MOTIF PROMPT>\",\n \"size\": \"1536x1024\",\n \"quality\": \"high\"\n }' | jq -r '.data[0].b64_json' | base64 -d > images/lab/hero-openai-1.png\n```\n\nSizes: `1024x1024`, `1536x1024` (landscape), `1024x1536` (portrait).\n\n### Higgsfield (MCP server — no API key)\n\nHiggsfield exposes its models through a hosted MCP server at\n`https://mcp.higgsfield.ai/mcp` (OAuth-protected — first connect opens a\nbrowser login; no secret lands in any file). Add it next to the\ntyperoll server in the site folder's `.mcp.json`:\n\n```json\n\"higgsfield\": { \"type\": \"http\", \"url\": \"https://mcp.higgsfield.ai/mcp\" }\n```\n\nThen use its tools directly — list what's available rather than\nassuming tool names. Save/download outputs into `images/lab/` with the\nsame naming convention, and prepend the style profile to prompts here\ntoo. **Headless caveat:** OAuth-protected MCP servers need an existing\nlogin session on the machine — connect once interactively before\nrelying on it in a headless run. (The same URL works as a custom\nconnector in Claude Desktop, for editors who don't use Claude Code.)\n\n## Review → pick → upload\n\n1. **Look at every candidate** (Read the image files) and write one line\n per candidate in `build-log.md` (keep/reject + why).\n2. Show the user the shortlist (file paths) and let them pick — unless\n they've delegated the pick to you.\n3. Upload winners with the regular media tools (see `tr-images`):\n - small files → `upload_media_inline` (base64);\n - larger → `create_upload_url` + HTTP PUT + `finalize_media`.\n Give real `alt` text and a descriptive filename at upload time.\n4. `generate_image_variants` for responsive sizes when the image is\n placed full-width.\n5. Place via `update_block` (`core/image`, hero fields, …) or page HTML.\n6. `images/lab/` is disposable — leave rejects there; never upload them.\n\n## Pitfalls\n\n- **Never put text in generated images** (headlines, buttons) — text is\n HTML's job; generated text renders as gibberish in non-English. And\n models sneak text onto surfaces where it \"feels natural\" — jerseys,\n signs, banners, packages — even when the prompt doesn't ask for any.\n Forbid it explicitly in the prompt (\"plain unmarked clothing, no\n text, no letters, no numbers anywhere in the image\") and make the\n same rule part of every site's style profile.\n- **Don't commit `.env` or `images/lab/`** — both are gitignored by the\n kit convention; keep it that way.\n- **Cost discipline:** generation costs real money per image. Batch\n thoughtfully (3–6 per slot, not 20), and reuse via Gemini's\n edit-an-image mode instead of regenerating from scratch.\n- **Licensing/provenance:** AI-generated imagery is fine for site\n decoration, but never generate fake \"photos\" of real people, products\n the customer doesn't sell, or anything presented as documentary fact.\n- **The style profile is the contract.** If the user rejects a batch on\n style grounds, fix `prompts/image-style.md` first, then regenerate —\n don't just tweak one prompt.\n",
@@ -30,9 +30,10 @@ export const BUNDLED_SKILLS = {
30
30
  export const BUNDLED_WORKSPACE_TEMPLATES = {
31
31
  "brief/interview.md": "# Interview progress\n\nStatus: not started\nNext question: goal and scope\n\n## Confirmed answers\n\n| Topic | Answer | Source/date | Brief location |\n| --- | --- | --- | --- |\n\n## Proposals and unknowns\n\nRecord what is not yet decided, whether it blocks the next step and who can decide.\n\n## Recap and approved next step\n\nRecord the owner's confirmation, exact scope and date. Do not infer deployment,\nDNS or message approval from a completed brief. No secrets or raw private transcripts.\n",
32
32
  "brief/migration.md": "# Migration brief (when applicable)\n\n## Independent choices\n\n- text_intent: undecided (preserve or renew)\n- design_intent: undecided (preserve or renew; includes images)\n\nThese are planning labels, not API parameters. Choose each separately. If guidance\nis needed, use workflows/interview.md. A single \"renew\" must not silently authorize\nboth. Expand an older single-choice brief with the owner before dependent writes.\n\n## Source URLs, date and agreed scope\n\n## Text policy and exceptions\n\nPreserve wording, headings, CTAs and captions when text_intent is preserve. Record\nSEO metadata/alt text, legal copy, translation and page-specific exceptions explicitly.\nFor renewal, record the approved editorial brief and facts that must remain true.\n\n## Design and image policy and exceptions\n\nRecord layout/brand references, required logos/assets, permitted replacements or\ngeneration, cropping, rights and credits. Design renewal alone never permits text\nrewriting. Preserve image motifs unless a replacement is agreed; format/size\noptimization does not mean changing the image's content.\n\n## Acceptance references\n\nText: original source for preserve; approved editorial brief for renew.\nDesign/images: original source for preserve; approved design/prototypes for renew.\nRecord these separately per layout or page when exceptions apply.\n\n## Standard blocks, custom types and HTML exceptions\n\n## Old URL to preserved path or relevant redirect\n\nBoth choices retain URL continuity, source facts and required business behavior.\n\n## Prototype and verification evidence\n\nRecord text, design/images, links and behavior results separately. Keep private\nextracts in sources/private/ and screenshots in qa/reports/.\n",
33
- "qa/checklist.md": "# Verification\n\n- Confirm Organization, Site, Version and deployed/source identity.\n- Run `python3 scripts/typeroll/check_unsaved.py` before trigger_deploy. Review\n and explicitly save authorized Pages and partials; templates save immediately.\n- Compare preserved text with capture_text.cjs + compare_text.py and an explicit\n selector. Check intentional additions, source comments and images separately.\n- Inspect desktop/mobile previews and actual hosted output around breakpoints.\n Use shootframe.cjs / shoot_hosted.cjs and inspect full screenshots, not only tiles.\n- Exercise menus, keyboard focus, current links, forms and enabled app states.\n Use card-stacking-check.cjs for whole-card excerpt/media/whitespace targets.\n- Check actual contrast on light/dark/photo backgrounds, image subjects and\n sharpness, footer balance, left-aligned last rows and heading hierarchy.\n- Check home title/suffix, metadata, canonical/indexing policy and JSON-LD.\n- Run check_url_parity.py and check_assets.py on the deployed candidate; compare\n final targets with the approved URL map and flatten accidental redirect chains.\n- Treat 403/network/DNS failures as unverified. Do not claim search-bot access\n from a user-agent string or turn off customer protection without approval.\n- Record tested pages, viewports, revision, results and unresolved issues per QA\n round. Record product defects in qa/typeroll-platform-feedback.md.\n- Keep screenshots and private runtime reports in ignored qa/reports/.\n- Check publishing readiness and migration launch report. Passing technical\n validation does not prove visual/editorial completeness or grant launch approval.\n\nCommands/dependencies/limits: scripts/README.md. Design: workflows/design.md.\nFollow existing owner authorization; test publication, traffic cutover and\nindexing have distinct effects but do not require repeated approval if covered.\n\n\n- [ ] Check heading role spacing, slot gap and responsive vertical padding in the browser.\n- [ ] Review global/current navigation colors against the real parent background.\n- [ ] Check intrinsic-width image and full-image hero framing at breakpoint boundaries.\n- [ ] Test click-to-load video with keyboard; no provider request before activation.\n- [ ] Review matched drafts, unpublished targets and redirect chains before launch.\n- [ ] Explicitly save working copies, or obtain approval to publish saved content only.\n\n## Design tokens (Core 0.2.106+)\n\n- [ ] Run `lint_design_tokens` before launch and after major design changes.\n- [ ] Review hardcoded colours, fonts and sizes; record justified exceptions in decisions.md.\n- [ ] Dry-run colour migration; do not infer semantic roles from equal hex values.\n- [ ] Change primary and surface in a test Version: header, footer, cards, menus and buttons follow without block edits at mobile/tablet/desktop widths. Restore the test settings.\n- [ ] Report actual versus simulated before/after counts separately; do not claim a migration was applied from a dry run.\n\n- [ ] Before source retirement, review workflows/source-media.md: unused originals,\n print files, private archive manifest, verified copies and a restore test.\n- [ ] Review ICC-converted derivatives visually; preserve originals separately.\n- [ ] Verify Analytics configuration and actual beacon/cookie behavior after build.\n- [ ] Read domain publication intent before deployment; distinguish test-host\n updates, future-host candidates and explicit traffic approval.\n- [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.\n- [ ] Review owner facts and dates against the chosen preserve/renew text policy.\n",
33
+ "pages/README.md": "# Internal Page notes\n\nKeep internal sources, decisions, comments and dated history in this repository,\nnot in public CMS fields. The CMS remains the source of current content; notes\nare editorial context, never a second content copy. Keep this workspace private\nif notes contain private information. Never put credentials or signed URLs here.\nDo not include pages/ in static output, content imports or public source bundles.\n\nOne file per stable Page ID: pages/<encoded-page-id>.md. The helper percent-encodes\nIDs that contain path characters. URLs and titles can change without renaming notes.\nUse a separate workspace/Git branch for another Site or Version; the generated\nfacts are bound to portal, Organization, Site and Version and reject mismatches.\n\nEach note has review, next_review (YYYY-MM or YYYY-MM-DD), last_checked and\nresponsible metadata, followed by Purpose, Sources (including date and whether an\nowner statement), Publishing considerations, Open questions and Log. Use dated\nchange, decision, comment and check entries; keep the newest first. Site-wide\nrules belong in decisions.md, referenced from the relevant notes.\n\nChoose a review cadence for the project, for example quarterly, annual, event-led\nor when source facts change. There is no default season or automatic review date.\nRefreshing facts never changes last_checked or next_review. Set those only after\nan actual review. index.md sorts scheduled reviews first, then unscheduled Pages.\n\nThe generated block contains saved CMS metadata and candidate links from the saved\nbody. It does not inspect template/partial links, JavaScript or final rendering;\nquery-bearing URLs are omitted to avoid copying credentials. Check the rendered\nPage and every external link during review. Sources, historical import statistics\nand their dates belong in the human sections, not invented by the script.\n\nRun scripts/typeroll/page_notes.py --dry-run, inspect, then omit --dry-run to refresh.\n--migration-inventory optionally includes recorded redirect sources. Run from the\nworkspace root with a privately injected TYPEROLL_API_KEY. No CMS writes occur.\nOnly the generated markers and index.md are refreshed; malformed markers fail\nwithout overwriting notes. Reconcile existing notes manually before adopting the\nnew marker/scope format; do not replace an established project's editorial history.\n\nNotes without a Page are reported, never deleted automatically. Confirm deletion\nand any redirect, add a final log entry, then move the note to pages/archived/.\nDo not archive just because an API call failed or access changed.\n",
34
+ "qa/checklist.md": "# Verification\n\n- Confirm Organization, Site, Version and deployed/source identity.\n- Run `python3 scripts/typeroll/check_unsaved.py` before trigger_deploy. Review\n and explicitly save authorized Pages and partials; templates save immediately.\n- Compare preserved text with capture_text.cjs + compare_text.py and an explicit\n selector. Check intentional additions, source comments and images separately.\n- Inspect desktop/mobile previews and actual hosted output around breakpoints.\n Use shootframe.cjs / shoot_hosted.cjs and inspect full screenshots, not only tiles.\n- Exercise menus, keyboard focus, current links, forms and enabled app states.\n Use card-stacking-check.cjs for whole-card excerpt/media/whitespace targets.\n- Check actual contrast on light/dark/photo backgrounds, image subjects and\n sharpness, footer balance, left-aligned last rows and heading hierarchy.\n- Check home title/suffix, metadata, canonical/indexing policy and JSON-LD.\n- Run check_url_parity.py and check_assets.py on the deployed candidate; compare\n final targets with the approved URL map and flatten accidental redirect chains.\n- Treat 403/network/DNS failures as unverified. Do not claim search-bot access\n from a user-agent string or turn off customer protection without approval.\n- Record tested pages, viewports, revision, results and unresolved issues per QA\n round. Record product defects in qa/typeroll-platform-feedback.md.\n- Keep screenshots and private runtime reports in ignored qa/reports/.\n- Check publishing readiness and migration launch report. Passing technical\n validation does not prove visual/editorial completeness or grant launch approval.\n\nCommands/dependencies/limits: scripts/README.md. Design: workflows/design.md.\nFollow existing owner authorization; test publication, traffic cutover and\nindexing have distinct effects but do not require repeated approval if covered.\n\n\n- [ ] Check heading role spacing, slot gap and responsive vertical padding in the browser.\n- [ ] Review global/current navigation colors against the real parent background.\n- [ ] Check intrinsic-width image and full-image hero framing at breakpoint boundaries.\n- [ ] Test click-to-load video with keyboard; no provider request before activation.\n- [ ] Review matched drafts, unpublished targets and redirect chains before launch.\n- [ ] Explicitly save working copies, or obtain approval to publish saved content only.\n\n## Design tokens (Core 0.2.106+)\n\n- [ ] Run `lint_design_tokens` before launch and after major design changes.\n- [ ] Review hardcoded colours, fonts and sizes; record justified exceptions in decisions.md.\n- [ ] Dry-run colour migration; do not infer semantic roles from equal hex values.\n- [ ] Change primary and surface in a test Version: header, footer, cards, menus and buttons follow without block edits at mobile/tablet/desktop widths. Restore the test settings.\n- [ ] Report actual versus simulated before/after counts separately; do not claim a migration was applied from a dry run.\n\n- [ ] Before source retirement, review workflows/source-media.md: unused originals,\n print files, private archive manifest, verified copies and a restore test.\n- [ ] Review ICC-converted derivatives visually; preserve originals separately.\n- [ ] Verify Analytics configuration and actual beacon/cookie behavior after build.\n- [ ] Read domain publication intent before deployment; distinguish test-host\n updates, future-host candidates and explicit traffic approval.\n- [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.\n- [ ] Review owner facts and dates against the chosen preserve/renew text policy.\n\n## Internal Page notes\n\nFollow workflows/page-notes.md and pages/README.md. Create notes during migration,\nread before edits, update afterward, check external links at each editorial review,\nand archive notes for confirmed removed Pages under pages/archived/. Refreshing\ngenerated facts never counts as a completed review. Keep notes out of public output.\n",
34
35
  "qa/typeroll-platform-feedback.md": "# Platform feedback\n\nRecord site-specific observations here, without credentials or private exports.\n\n| ID | Priority | Surface / version | Observation | Evidence | Workaround | Status |\n| --- | --- | --- | --- | --- | --- | --- |\n\nFor each finding record expected versus actual behavior, minimal reproduction,\nSite/Version, page/block locator, relevant schema, tested widths, source revision\nand whether it blocks launch. Distinguish bugs, capabilities, instructions and\nproject choices. Link upstream fixes; verify deployed output before closing them.\n",
35
- "scripts/README.md": "# Repeatable site QA\n\nThese optional tools supplement MCP and the platform's publication validator.\nThey do not publish, change DNS, approve a launch or replace visual review.\nPython tools use the standard library except contact_sheet.py (Pillow). Browser\ntools require Node.js 22+, Playwright and its Chromium browser. From scripts/typeroll:\n\n```sh\nnpm install --no-save --package-lock=false playwright\nnpx playwright install chromium\n```\n\nRun the commands below from the **workspace root**. Inject TYPEROLL_API_KEY through\nyour private credential manager. Never put it in a file or command argument.\ntrlib.py reads portal, organization_id, site_id and version from typeroll.json,\nchecks the connected Site/Organization, discovers the live tool schema, and\npasses the Version where supported. It refuses unversioned operations on a\nnon-main binding; use the explicit interactive API workflow for organization or\nshared media operations instead. It is a small hosted MCP helper for API keys,\nnot a replacement OAuth client or a new general CRUD CLI. REST is also supported\nby Typeroll if your integration already uses it.\n\n## Before publication\n\n```sh\npython3 scripts/typeroll/check_unsaved.py\n```\n\nExit 1 lists unsaved Pages **and partials**. Review those changes; save only the\nwork authorized for this publication, then run again. Templates save immediately.\nA working-copy preview is not proof of saved output. Never auto-save other work.\n\n## After an authorized publication\n\n```sh\npython3 scripts/typeroll/check_url_parity.py https://YOUR-TEST-HOST\npython3 scripts/typeroll/check_assets.py https://YOUR-TEST-HOST\nnode scripts/typeroll/shoot_hosted.cjs https://YOUR-TEST-HOST qa/reports/hosted /,/about/ 390 768 1023 1024 1280\nnode scripts/typeroll/card-stacking-check.cjs https://YOUR-TEST-HOST/listing/ 390\n```\n\nURL parity reads every migration inventory page and observed spelling, skips\nexplicit exclusions unless --show-excluded, keeps origins/query parameters,\ndetects loops and flags chains beyond a single redirect (one initial slash\nnormalization is allowed). This does **not** prove the final destination is the\neditorially correct page: compare with the approved URL map too. No inventory\nis a failure, not a passing zero-row check. Asset QA uses resolved routes,\nchecks published/unlisted Pages, relative URLs, srcset and downloads, uses four\nconcurrent HEAD requests and falls back to GET when HEAD is unsupported. CSS\nbackgrounds, authenticated assets and dynamically loaded resources need browser QA.\nNeither tool spoofs a search crawler. A 403/DNS/network failure is unverified;\ninspect access policy rather than treating it as a broken migration or a pass.\n\n## Other helpers\n\n- `call.py TOOL '{\"argument\":\"value\"}'`: raw MCP result; use `-` to read JSON\n from stdin. Mutating tools require the same owner authorization as interactive\n calls. Network failures are not automatically retried for mutations.\n- `trlib.upload(path, content_type, alt)`: direct signed upload and mandatory\n finalize SHA-256 verification. No image bytes pass through the model or Git.\n- `sweep_blocks.example.py`: dry-run grid normalization across Pages, templates\n and partials. Review before --apply; refuses to commit existing unsaved work.\n It is not transactional: a failure can leave a partial draft; inspect before retry.\n- `shootframe.cjs OUT-PREFIX SIGNED-PREVIEW-URL [widths...]`: selects only the\n Typeroll preview iframe, reports fonts/hosts/overflow. Signed URLs are secrets;\n pass privately, never paste into committed reports or shell history. Direct-frame\n capture checks rendering only; test Extension storage/navigation/handoffs in\n the original wrapper because those features depend on its parent bridge.\n- `layout_trace.cjs URL [width] [selector]`: computed spacing of ancestors and\n next section. Works with a direct page or Typeroll preview.\n- `contact_sheet.py OUT-PREFIX COLS TILE-WIDTH PNG...`: Pillow contact sheets.\n It crops tall screenshots; inspect full screenshots too.\n- `capture_text.cjs URL qa/reports/text.json 'main .article-content'` followed by\n `compare_text.py sources/private/page.html qa/reports/text.json --source-title 'Title'`:\n word/punctuation diff. A saved single WP REST record (.json) is another source\n adapter. Select exactly the preserved region, including its template-owned\n title once. Deliberate shared additions need separate acceptance. Comments,\n scripts and styles are excluded from source text. It never edits content.\n\nBrowser reports exit nonzero for missing images, overflow, unexpected H1 count,\nfailed requests or incomplete scrolling. They keep third-party failures visible.\nNetwork checks retry only a bounded read. All reports, screenshots and source\nexports stay under ignored qa/reports/ or sources/private/. Do not commit signed\nURLs, personal data or customer assets. Adapt scripts deliberately and run tests\nbefore applying broad changes. The workspace updater preserves your edits.\n\n## Palette migration (Core 0.2.106 / MCP 0.45.80+)\n\n`color_tokens.py --plan /tmp/design-token-plan.json` runs the read-only\n`lint_design_tokens` audit and lists unambiguous direct block hex replacements.\nReview the plan and remove deliberate exceptions. `--apply /tmp/design-token-plan.json`\nrequires the same site/palette and a dedicated non-main Version without existing\nworking copies. Pause other editing in that Version during the run: ordinary\nfield writes do not provide a site-wide transaction. The script refuses stale\nvalues, never retries uncertain writes, and never publishes. On an interruption,\ninspect working copies before saving or resuming. Templates save immediately.\n\nAmbiguous colours, nested/responsive colour maps and custom CSS are reported for\nmanual review rather than rewritten. API updates through the normal block tools\ncan resolve these after choosing the semantic role. Run lint again and compare\nheader, footer, navigation, cards and buttons at all breakpoints. Keep generated\nplans outside Git; they are version-specific review artifacts.\n\n## Local image preparation\n\n`prepare_image.py SOURCE OUTPUT.jpg` requires Pillow with ImageCms. It converts\nthrough the original ICC profile to sRGB and applies orientation, without\nuploading or changing CMS content. Missing CMYK profiles, transparency, multiple\nframes and existing output files stop with an error. `--assume-srgb` is only for\nreviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.\nSee workflows/source-media.md for archive verification and original preservation.\n",
36
+ "scripts/README.md": "# Repeatable site QA\n\nThese optional tools supplement MCP and the platform's publication validator.\nThey do not publish, change DNS, approve a launch or replace visual review.\nPython tools use the standard library except contact_sheet.py (Pillow). Browser\ntools require Node.js 22+, Playwright and its Chromium browser. From scripts/typeroll:\n\n```sh\nnpm install --no-save --package-lock=false playwright\nnpx playwright install chromium\n```\n\nRun the commands below from the **workspace root**. Inject TYPEROLL_API_KEY through\nyour private credential manager. Never put it in a file or command argument.\ntrlib.py reads portal, organization_id, site_id and version from typeroll.json,\nchecks the connected Site/Organization, discovers the live tool schema, and\npasses the Version where supported. It refuses unversioned operations on a\nnon-main binding; use the explicit interactive API workflow for organization or\nshared media operations instead. It is a small hosted MCP helper for API keys,\nnot a replacement OAuth client or a new general CRUD CLI. REST is also supported\nby Typeroll if your integration already uses it.\n\n## Before publication\n\n```sh\npython3 scripts/typeroll/check_unsaved.py\n```\n\nExit 1 lists unsaved Pages **and partials**. Review those changes; save only the\nwork authorized for this publication, then run again. Templates save immediately.\nA working-copy preview is not proof of saved output. Never auto-save other work.\n\n## After an authorized publication\n\n```sh\npython3 scripts/typeroll/check_url_parity.py https://YOUR-TEST-HOST\npython3 scripts/typeroll/check_assets.py https://YOUR-TEST-HOST\nnode scripts/typeroll/shoot_hosted.cjs https://YOUR-TEST-HOST qa/reports/hosted /,/about/ 390 768 1023 1024 1280\nnode scripts/typeroll/card-stacking-check.cjs https://YOUR-TEST-HOST/listing/ 390\n```\n\nURL parity reads every migration inventory page and observed spelling, skips\nexplicit exclusions unless --show-excluded, keeps origins/query parameters,\ndetects loops and flags chains beyond a single redirect (one initial slash\nnormalization is allowed). This does **not** prove the final destination is the\neditorially correct page: compare with the approved URL map too. No inventory\nis a failure, not a passing zero-row check. Asset QA uses resolved routes,\nchecks published/unlisted Pages, relative URLs, srcset and downloads, uses four\nconcurrent HEAD requests and falls back to GET when HEAD is unsupported. CSS\nbackgrounds, authenticated assets and dynamically loaded resources need browser QA.\nNeither tool spoofs a search crawler. A 403/DNS/network failure is unverified;\ninspect access policy rather than treating it as a broken migration or a pass.\n\n## Other helpers\n\n- `call.py TOOL '{\"argument\":\"value\"}'`: raw MCP result; use `-` to read JSON\n from stdin. Mutating tools require the same owner authorization as interactive\n calls. Network failures are not automatically retried for mutations.\n- `trlib.upload(path, content_type, alt)`: direct signed upload and mandatory\n finalize SHA-256 verification. No image bytes pass through the model or Git.\n- `sweep_blocks.example.py`: dry-run grid normalization across Pages, templates\n and partials. Review before --apply; refuses to commit existing unsaved work.\n It is not transactional: a failure can leave a partial draft; inspect before retry.\n- `shootframe.cjs OUT-PREFIX SIGNED-PREVIEW-URL [widths...]`: selects only the\n Typeroll preview iframe, reports fonts/hosts/overflow. Signed URLs are secrets;\n pass privately, never paste into committed reports or shell history. Direct-frame\n capture checks rendering only; test Extension storage/navigation/handoffs in\n the original wrapper because those features depend on its parent bridge.\n- `layout_trace.cjs URL [width] [selector]`: computed spacing of ancestors and\n next section. Works with a direct page or Typeroll preview.\n- `contact_sheet.py OUT-PREFIX COLS TILE-WIDTH PNG...`: Pillow contact sheets.\n It crops tall screenshots; inspect full screenshots too.\n- `capture_text.cjs URL qa/reports/text.json 'main .article-content'` followed by\n `compare_text.py sources/private/page.html qa/reports/text.json --source-title 'Title'`:\n word/punctuation diff. A saved single WP REST record (.json) is another source\n adapter. Select exactly the preserved region, including its template-owned\n title once. Deliberate shared additions need separate acceptance. Comments,\n scripts and styles are excluded from source text. It never edits content.\n\nBrowser reports exit nonzero for missing images, overflow, unexpected H1 count,\nfailed requests or incomplete scrolling. They keep third-party failures visible.\nNetwork checks retry only a bounded read. All reports, screenshots and source\nexports stay under ignored qa/reports/ or sources/private/. Do not commit signed\nURLs, personal data or customer assets. Adapt scripts deliberately and run tests\nbefore applying broad changes. The workspace updater preserves your edits.\n\n## Palette migration (Core 0.2.106 / MCP 0.45.80+)\n\n`color_tokens.py --plan /tmp/design-token-plan.json` runs the read-only\n`lint_design_tokens` audit and lists unambiguous direct block hex replacements.\nReview the plan and remove deliberate exceptions. `--apply /tmp/design-token-plan.json`\nrequires the same site/palette and a dedicated non-main Version without existing\nworking copies. Pause other editing in that Version during the run: ordinary\nfield writes do not provide a site-wide transaction. The script refuses stale\nvalues, never retries uncertain writes, and never publishes. On an interruption,\ninspect working copies before saving or resuming. Templates save immediately.\n\nAmbiguous colours, nested/responsive colour maps and custom CSS are reported for\nmanual review rather than rewritten. API updates through the normal block tools\ncan resolve these after choosing the semantic role. Run lint again and compare\nheader, footer, navigation, cards and buttons at all breakpoints. Keep generated\nplans outside Git; they are version-specific review artifacts.\n\n## Local image preparation\n\n`prepare_image.py SOURCE OUTPUT.jpg` requires Pillow with ImageCms. It converts\nthrough the original ICC profile to sRGB and applies orientation, without\nuploading or changing CMS content. Missing CMYK profiles, transparency, multiple\nframes and existing output files stop with an error. `--assume-srgb` is only for\nreviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.\nSee workflows/source-media.md for archive verification and original preservation.\n\n## Internal Page notes\n\n`page_notes.py --dry-run` previews local note creation/fact refresh and index updates.\nOmit --dry-run to write local files; --migration-inventory adds recorded redirect\nsources. It never writes to the CMS, marks a Page reviewed or deletes notes. Read\npages/README.md and workflows/page-notes.md before first use.\n",
36
37
  "scripts/typeroll/browser.cjs": "// Isolated QA browser only. Never persist storage state, cookies or signed URLs.\nconst { chromium } = require('playwright');\nconst fs = require('node:fs/promises');\nconst path = require('node:path');\nasync function open(page, url) {\n await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });\n // Select only Typeroll's own preview, never an arbitrary ad/video iframe.\n const frame = page.locator('iframe[title=\"Preview\"], iframe[title=\"Site preview\"]');\n if (await frame.count() === 1) {\n await page.waitForFunction(() => document.querySelector('iframe[title=\"Preview\"], iframe[title=\"Site preview\"]')?.getAttribute('src'), null, { timeout: 15000 });\n const src = await frame.getAttribute('src');\n if (!src) throw Error('Preview frame has no source');\n const target = new URL(src, page.url());\n if (target.origin !== new URL(page.url()).origin) throw Error('Unexpected preview frame origin');\n await page.goto(target.href, { waitUntil: 'domcontentloaded', timeout: 60000 });\n }\n await page.evaluate(() => document.fonts.ready);\n}\nasync function settle(page) {\n // Bounded for infinite-scroll pages; report coverage rather than hanging.\n for (let y = 0; y < 30000; y += 600) {\n await page.evaluate(y => scrollTo(0, y), y);\n await page.waitForTimeout(100);\n if (y + 900 >= await page.evaluate(() => document.documentElement.scrollHeight)) break;\n }\n await page.evaluate(() => scrollTo(0, 0));\n await page.waitForTimeout(300);\n}\nasync function inspect(page) {\n return page.evaluate(() => ({\n overflow: document.documentElement.scrollWidth > innerWidth,\n h1: document.querySelectorAll('h1').length,\n title: document.title,\n robots: document.querySelector('meta[name=\"robots\"]')?.content ?? null,\n fonts: { body: getComputedStyle(document.body).fontFamily, heading: getComputedStyle(document.querySelector('h1') || document.body).fontFamily },\n incompleteImages: [...document.images].filter(i => !i.complete || !i.naturalWidth).map(i => new URL(i.currentSrc || i.src, location.href).pathname),\n scrollCoverageComplete: document.documentElement.scrollHeight <= 30000,\n }));\n}\nasync function screenshot(page, file) {\n await fs.mkdir(path.dirname(file), { recursive: true });\n await page.screenshot({ path: file, fullPage: true, animations: 'disabled' });\n}\nfunction widths(args) {\n const values = (args.length ? args : ['390', '768', '1023', '1024', '1280']).map(Number);\n if (values.some(w => !Number.isInteger(w) || w < 240 || w > 4000)) throw Error('Invalid viewport width');\n return values;\n}\nfunction fail() { console.error('QA failed; inspect the target privately. URLs and credentials are withheld.'); process.exitCode = 2; }\nmodule.exports = { chromium, open, settle, inspect, screenshot, widths, fail };\n",
37
38
  "scripts/typeroll/call.py": "\"\"\"One MCP call; output can contain private data. Do not commit it.\"\"\"\nimport argparse\nimport json\nimport sys\nimport trlib\n\nparser = argparse.ArgumentParser(description=__doc__)\nparser.add_argument('tool')\nparser.add_argument('arguments', nargs='?', default='{}', help='JSON or - for stdin')\nargs = parser.parse_args()\nvalue = json.loads(sys.stdin.read() if args.arguments == '-' else args.arguments)\nprint(json.dumps(trlib.call(args.tool, value), ensure_ascii=False, indent=2))\n",
38
39
  "scripts/typeroll/capture_text.cjs": "// Explicit selector avoids silently excluding the wrong column/sidebar.\nconst qa = require('./browser.cjs');\nconst fs = require('node:fs/promises');\nconst [url, out, selector = 'main'] = process.argv.slice(2);\n(async () => {\n const browser = await qa.chromium.launch();\n try {\n const page = await browser.newPage();\n await qa.open(page, url); await qa.settle(page);\n const target = page.locator(selector);\n if (await target.count() !== 1) throw Error('Select exactly one content region');\n const value = await target.evaluate(el => ({ text: el.innerText,\n images: [...el.querySelectorAll('img')].map(i => ({ alt: i.alt, path: new URL(i.currentSrc || i.src, location.href).pathname })) }));\n await fs.mkdir(require('node:path').dirname(out), { recursive: true });\n await fs.writeFile(out, JSON.stringify(value, null, 2));\n } finally { await browser.close(); }\n})().catch(qa.fail);\n",
@@ -44,15 +45,17 @@ export const BUNDLED_WORKSPACE_TEMPLATES = {
44
45
  "scripts/typeroll/compare_text.py": "\"\"\"Compare saved source HTML (or one WP REST record) to a captured target.\nTemplate titles are included exactly once by explicit source title/target selector.\nThis reports differences, never rewrites content or approves a migration.\n\"\"\"\nimport argparse\nimport difflib\nfrom html.parser import HTMLParser\nimport json\nfrom pathlib import Path\nimport re\n\n\nclass Text(HTMLParser):\n def __init__(self):\n super().__init__(convert_charrefs=True)\n self.words, self.skip = [], 0\n def handle_starttag(self, tag, attrs):\n if tag in ('script', 'style'):\n self.skip += 1\n elif tag in ('p', 'div', 'li', 'br', 'h1', 'h2', 'h3', 'h4'):\n self.words.append(' ')\n def handle_endtag(self, tag):\n if tag in ('script', 'style'):\n self.skip = max(0, self.skip - 1)\n elif tag in ('p', 'div', 'li', 'h1', 'h2', 'h3', 'h4'):\n self.words.append(' ')\n def handle_data(self, value):\n if not self.skip:\n self.words.append(value)\n # HTMLParser excludes comments. Do not strip tags with a regexp.\n\n\ndef tokens(value):\n parser = Text()\n parser.feed(value)\n return re.findall(r'\\w+|[^\\w\\s]', ''.join(parser.words))\n\n\ndef main():\n args = argparse.ArgumentParser(description=__doc__)\n args.add_argument('source', help='HTML fragment or one saved WP REST record (.json)')\n args.add_argument('target', help='JSON from capture_text.cjs')\n args.add_argument('--source-title', default='', help='For an HTML source whose title is template-owned')\n opts = args.parse_args()\n source = Path(opts.source).read_text()\n if opts.source.endswith('.json'):\n record = json.loads(source)\n source = record['title']['rendered'] + ' ' + record['content']['rendered']\n else:\n source = opts.source_title + ' ' + source\n target = json.loads(Path(opts.target).read_text())\n before, after = tokens(source), re.findall(r'\\w+|[^\\w\\s]', target['text'])\n changes = [line for line in difflib.ndiff(before, after) if line.startswith(('+ ', '- '))]\n print(json.dumps({'source_tokens': len(before), 'target_tokens': len(after), 'changes': changes}, ensure_ascii=False, indent=2))\n return int(bool(changes))\n\n\nif __name__ == '__main__':\n raise SystemExit(main())\n",
45
46
  "scripts/typeroll/contact_sheet.py": "\"\"\"Tile screenshots into contact sheets for fast visual review of many pages.\nusage: python3 contact_sheet.py <out-prefix> <cols> <tile-width> <png> [<png> ...]\nEach tile shows the top of a screenshot (height = 3.2 x width) labelled with its file name.\"\"\"\nimport sys, os\nfrom PIL import Image, ImageDraw\nout, cols, tw, files = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), sys.argv[4:]\nth = int(tw * 3.2)\nper = cols * 2\nfor n, start in enumerate(range(0, len(files), per)):\n chunk = files[start:start + per]\n sheet = Image.new(\"RGB\", (cols * tw, 2 * (th + 20)), \"white\")\n d = ImageDraw.Draw(sheet)\n for i, f in enumerate(chunk):\n im = Image.open(f).convert(\"RGB\")\n im = im.resize((tw, int(im.height * tw / im.width))).crop((0, 0, tw, th))\n x, y = (i % cols) * tw, (i // cols) * (th + 20)\n sheet.paste(im, (x, y + 20))\n d.text((x + 3, y + 3), os.path.basename(f)[:40], fill=\"red\")\n sheet.save(f\"{out}-{n}.png\")\n print(f\"{out}-{n}.png\")\n",
46
47
  "scripts/typeroll/layout_trace.cjs": "// Explain vertical spacing: computed margin/padding/gap of every ancestor of a selector and of the next\n// section down to its first heading. Works on a signed preview link (reads the inner frame) or a public URL.\n// usage: node layout_trace.js <url> [width=390] [selector=h1]\nconst qa = require('./browser.cjs');\nconst { chromium } = qa;\n(async () => {\n const [,, url, width = '390', sel = 'h1'] = process.argv;\n const b = await chromium.launch();\n const p = await b.newPage({viewport: {width: +width, height: 900}});\n await qa.open(p, url);\n const f = p.mainFrame();\n try { console.log(await f.evaluate((sel) => {\n const out = [];\n const show = (lab, el) => {\n if (!el) return;\n const c = getComputedStyle(el), r = el.getBoundingClientRect();\n out.push(`${lab} <${el.tagName.toLowerCase()} ${el.getAttribute('data-block') || String(el.className).slice(0, 40)}> top=${Math.round(r.top)} h=${Math.round(r.height)} margin=${c.marginTop}/${c.marginBottom} padding=${c.paddingTop}/${c.paddingBottom} gap=${c.rowGap} radius=${c.borderRadius}`);\n };\n const target = document.querySelector(sel);\n if (!target) throw Error('Selector not found');\n const sec = target.closest('[data-block=\"section\"]') || target;\n const chain = []; for (let a = target; a && a !== document.body; a = a.parentElement) chain.push(a);\n chain.reverse().forEach((x, i) => show('ancestor' + i, x));\n for (let s = sec.nextElementSibling; s; s = s.nextElementSibling) {\n show('next', s);\n const h = s.querySelector('h1,h2,h3');\n if (h) { const ch = []; for (let q = h; q && q !== s; q = q.parentElement) ch.push(q); ch.reverse().forEach((x, i) => show('inside' + i, x)); break; }\n }\n show('header', document.querySelector('header'));\n return out.join('\\n');\n }, sel));\n } finally { await b.close(); }\n})().catch(qa.fail);\n",
48
+ "scripts/typeroll/page_notes.py": "\"\"\"Refresh repository-only Page facts; never edit CMS content or human notes.\n\nRun from the workspace root. --dry-run previews local changes. Optional\n--migration-inventory adds saved migration URL facts. Credentials use trlib.\n\"\"\"\nimport argparse\nfrom datetime import date\nimport hashlib\nfrom html.parser import HTMLParser\nimport json\nfrom pathlib import Path\nimport re\nfrom urllib.parse import quote, urlsplit\nimport trlib\n\nSTART = '<!-- generated:start (page_notes.py; do not edit by hand) -->'\nEND = '<!-- generated:end -->'\n\n\ndef filename(page_id):\n if not isinstance(page_id, str) or not page_id or page_id.lower() in ('readme', 'index', 'archived', '.', '..'):\n raise ValueError('Page ID is empty or conflicts with a reserved notes path')\n return quote(page_id, safe='-_') + '.md'\n\n\ndef cell(value):\n return str(value if value is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('|', '&#124;').replace('\\n', ' ').replace('\\r', ' ')\n\n\nclass Links(HTMLParser):\n def __init__(self):\n super().__init__()\n self.urls = set()\n\n def handle_starttag(self, tag, attrs):\n if tag == 'a':\n self.urls.update(v for k, v in attrs if k == 'href' and v)\n\n\ndef body_links(page, blocks):\n parser = Links()\n parser.feed(page.get('html_content') or '')\n\n def visit(value, key=''):\n if isinstance(value, dict):\n for k, v in value.items():\n visit(v, k)\n elif isinstance(value, list):\n for v in value:\n visit(v, key)\n elif isinstance(value, str):\n if key in ('href', 'link', 'url', 'button_url', 'link_url'):\n parser.urls.add(value)\n if '<a' in value.lower():\n parser.feed(value)\n for block in trlib.walk(blocks):\n visit(block.get('data') or {})\n # Do not persist signed preview links or query credentials in repository notes.\n return sorted(u for u in parser.urls if not urlsplit(u).query and not urlsplit(u).username\n and urlsplit(u).scheme in ('', 'http', 'https'))\n\n\ndef scope(config):\n return hashlib.sha256(json.dumps([config[k] for k in ('portal', 'organization_id', 'site_id', 'version')]).encode()).hexdigest()\n\n\ndef refresh(root, config, pages, links, migration=(), dry_run=False):\n \"\"\"Read all inputs before writing. Preserve every byte outside owned markers.\"\"\"\n out = root / 'pages'\n if out.is_symlink():\n raise ValueError('The notes directory must not be a symlink')\n names = [filename(p['id']) for p in pages]\n if len({n.lower() for n in names}) != len(names):\n raise ValueError('Page IDs collide on a case-insensitive filesystem')\n identity = '<!-- scope: ' + scope(config) + ' -->'\n planned, rows = {}, []\n for p, name in zip(pages, names):\n file = out / name\n if file.is_symlink():\n raise ValueError('A note must not be a symlink')\n text = file.read_text() if file.exists() else (\n '---\\nreview: unset\\nnext_review: \\nlast_checked: \\nresponsible: \\n---\\n\\n# ' + cell(p['title']) + '\\n\\n'\n + START + '\\n' + identity + '\\n' + END\n + '\\n\\n## Purpose\\n\\nTODO\\n\\n## Sources\\n\\nTODO\\n\\n## Publishing considerations\\n\\nTODO\\n\\n'\n '## Open questions\\n\\n- Set a review cadence and next review date.\\n\\n## Log\\n\\n- '\n + date.today().isoformat() + ' · comment · Note created; editorial review has not been performed.\\n')\n if text.count(START) != 1 or text.count(END) != 1 or text.index(START) >= text.index(END):\n raise ValueError('Missing or ambiguous generated markers in ' + name + '; reconcile manually')\n old = text.split(START)[1].split(END)[0]\n if identity not in old:\n raise ValueError('Note scope differs from the selected Organization/Site/Version: ' + name)\n facts = [START, identity, '', '| Fact | Value |', '| --- | --- |']\n for label, value in [('Page ID', p['id']), ('URL', p.get('url')), ('Title', p['title']),\n ('Status', p.get('status')), ('Content type', p.get('content_type')),\n ('Template', p.get('template')), ('Last saved', p.get('date_updated'))]:\n facts.append('| ' + label + ' | ' + cell(value) + ' |')\n aliases = [m['path'] for m in migration if m.get('status') == 'redirected' and m.get('target', '').rstrip('/') == p.get('url', '').rstrip('/')]\n if aliases:\n facts.append('| Recorded old URLs | ' + cell(', '.join(sorted(aliases))) + ' |')\n facts += ['', 'Saved body link candidates (not a complete rendered-link audit; query URLs omitted):', '']\n facts += ['- ' + cell(u) for u in links.get(p['id'], [])] or ['- None found.']\n facts += ['', END]\n new = text[:text.index(START)] + '\\n'.join(facts) + text[text.index(END) + len(END):]\n if new != text:\n planned[file] = new\n elif not file.exists():\n planned[file] = new\n fm = dict(re.findall(r'^(review|next_review|last_checked|responsible):[ \\t]*([^\\n]*)', new.split('---', 2)[1], re.M)) if new.startswith('---\\n') else {}\n nxt = fm.get('next_review', '').strip()\n if nxt and not re.fullmatch(r'\\d{4}-\\d{2}(?:-\\d{2})?', nxt):\n raise ValueError('Use YYYY-MM or YYYY-MM-DD for next_review in ' + name)\n rows.append((nxt or '9999', name, '| ' + (nxt or 'Not scheduled') + ' | [' + cell(p['title']).replace('[', '&#91;').replace(']', '&#93;') + '](' + name + ') | ' + cell(fm.get('review', 'unset')) + ' | ' + cell(fm.get('last_checked', '')) + ' |'))\n index = out / 'index.md'\n if index.is_symlink():\n raise ValueError('The notes index must not be a symlink')\n index_text = '# Page notes index\\n\\nGenerated from local notes; dates are editorial decisions, not CMS save dates.\\n\\n| Next review | Page | Cadence | Last checked |\\n| --- | --- | --- | --- |\\n' + '\\n'.join(r[2] for r in sorted(rows)) + '\\n'\n if not index.exists() or index.read_text() != index_text:\n planned[index] = index_text\n orphans = sorted(f.name for f in out.glob('*.md') if f.name not in {*names, 'README.md', 'index.md'})\n if not dry_run:\n out.mkdir(parents=True, exist_ok=True)\n for file, text in planned.items():\n file.write_text(text)\n return {'changed': [str(f.relative_to(root)) for f in planned], 'notes_without_page': orphans, 'dry_run': dry_run}\n\n\ndef main():\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument('--dry-run', action='store_true')\n parser.add_argument('--migration-inventory', action='store_true')\n args = parser.parse_args()\n root = Path.cwd()\n if not (root / 'typeroll.json').is_file():\n parser.error('Run from the workspace root containing typeroll.json')\n config = trlib.workspace()\n # list_pages(full=True) reads saved content, unlike read_page's draft overlay.\n pages = list(trlib.all_pages(full=True))\n links = {p['id']: body_links(p, p.get('blocks') or []) for p in pages}\n migration = list(trlib.all_migration_urls()) if args.migration_inventory else []\n print(json.dumps(refresh(root, config, pages, links, migration, args.dry_run), indent=2))\n\n\nif __name__ == '__main__':\n main()\n",
47
49
  "scripts/typeroll/prepare_image.py": "\"\"\"Convert a reviewed local original to an sRGB JPEG; never upload or change CMS content.\nRequires Pillow with ImageCms. Preserve the untouched original in the source archive.\nCMYK needs its actual embedded or supplied ICC profile. Never guess a CMYK profile.\nUnprofiled RGB/gray requires --assume-srgb after review. Output must not exist.\n\"\"\"\nimport argparse\nimport hashlib\nimport io\nimport json\nfrom pathlib import Path\n\n\ndef prepare(source, output, source_profile=None, assume_srgb=False, quality=95):\n from PIL import Image, ImageCms, ImageOps\n source, output = Path(source), Path(output)\n if output.exists() or source.resolve() == output.resolve():\n raise ValueError('Output already exists; choose a new derivative path.')\n if output.suffix.lower() not in ('.jpg', '.jpeg'):\n raise ValueError('Choose a .jpg or .jpeg output.')\n if not 1 <= quality <= 100:\n raise ValueError('JPEG quality must be from 1 to 100.')\n with Image.open(source) as original:\n if getattr(original, 'n_frames', 1) != 1:\n raise ValueError('Multi-page/animated originals require an explicit frame decision.')\n if 'A' in original.getbands() or 'transparency' in original.info:\n raise ValueError('Transparent originals need a reviewed background or a PNG workflow.')\n profile_bytes = Path(source_profile).read_bytes() if source_profile else original.info.get('icc_profile')\n image = ImageOps.exif_transpose(original)\n target = ImageCms.ImageCmsProfile(ImageCms.createProfile('sRGB'))\n if profile_bytes:\n profile = ImageCms.ImageCmsProfile(io.BytesIO(profile_bytes))\n converted = ImageCms.profileToProfile(image, profile, target, outputMode='RGB')\n elif image.mode in ('RGB', 'L') and assume_srgb:\n converted = image.convert('RGB')\n else:\n raise ValueError('Missing source ICC profile. CMYK cannot be safely guessed; supply the correct --source-profile. For reviewed unprofiled RGB/gray only, use --assume-srgb.')\n # No source EXIF/GPS/caption metadata is copied into the public derivative.\n with output.open('xb') as destination:\n converted.save(destination, format='JPEG', quality=quality, subsampling=0, icc_profile=target.tobytes())\n with Image.open(output) as check:\n check.load()\n if check.mode != 'RGB' or not check.info.get('icc_profile'):\n raise ValueError('Output verification failed; do not upload the derivative.')\n dimensions = list(check.size)\n return {'source_sha256': hashlib.sha256(source.read_bytes()).hexdigest(),\n 'output_sha256': hashlib.sha256(output.read_bytes()).hexdigest(),\n 'width': dimensions[0], 'height': dimensions[1], 'colour_space': 'sRGB',\n 'source_profile': 'supplied' if source_profile else 'embedded' if profile_bytes else 'assumed sRGB',\n 'uploaded': False}\n\n\ndef main():\n parser = argparse.ArgumentParser(description=__doc__)\n parser.add_argument('source', type=Path)\n parser.add_argument('output', type=Path)\n parser.add_argument('--source-profile', type=Path)\n parser.add_argument('--assume-srgb', action='store_true')\n parser.add_argument('--quality', type=int, default=95)\n args = parser.parse_args()\n print(json.dumps(prepare(args.source, args.output, args.source_profile, args.assume_srgb, args.quality), indent=2))\n\n\nif __name__ == '__main__':\n main()\n",
48
50
  "scripts/typeroll/shoot_hosted.cjs": "const qa = require('./browser.cjs');\nconst [origin, out, paths, ...args] = process.argv.slice(2);\n(async () => {\n const browser = await qa.chromium.launch();\n const report = [];\n try {\n for (const route of paths.split(',')) {\n const url = new URL(route, origin);\n if (url.origin !== new URL(origin).origin) throw Error('Paths must stay on the selected origin');\n for (const width of qa.widths(args)) {\n const page = await browser.newPage({ viewport: { width, height: 900 } });\n const failed = [];\n page.on('requestfailed', r => failed.push({ host: new URL(r.url()).hostname, kind: 'network' }));\n page.on('response', r => { if (r.status() >= 400) failed.push({ host: new URL(r.url()).hostname, status: r.status() }); });\n await qa.open(page, url.href); await qa.settle(page);\n const result = { path: route, width, ...await qa.inspect(page), failed };\n const name = require('node:crypto').createHash('sha256').update(route).digest('hex').slice(0, 12);\n await qa.screenshot(page, `${out}/${name}-${width}.png`);\n report.push(result);\n await page.close();\n }\n }\n console.log(JSON.stringify(report, null, 2));\n if (report.some(r => r.overflow || r.incompleteImages.length || r.h1 !== 1 || r.failed.length || !r.scrollCoverageComplete)) process.exitCode = 1;\n } finally { await browser.close(); }\n})().catch(qa.fail);\n",
49
51
  "scripts/typeroll/shootframe.cjs": "const qa = require('./browser.cjs');\nconst [out, url, ...args] = process.argv.slice(2);\n(async () => {\n const browser = await qa.chromium.launch();\n try {\n for (const width of qa.widths(args)) {\n const page = await browser.newPage({ viewport: { width, height: 900 } });\n const hosts = new Set();\n page.on('request', r => { try { hosts.add(new URL(r.url()).hostname); } catch {} });\n await qa.open(page, url); await qa.settle(page);\n await qa.screenshot(page, `${out}-${width}.png`);\n console.log(JSON.stringify({ width, ...await qa.inspect(page), hosts: [...hosts] }));\n await page.close();\n }\n } finally { await browser.close(); }\n})().catch(qa.fail);\n",
50
52
  "scripts/typeroll/sweep_blocks.example.py": "\"\"\"Pattern: change one kind of block on every Page, Page template and partial, then save.\nExample: set last_row \"start\" on every core/grid that centers its last row. Dry run lists; --apply writes.\nCopy and edit MATCH/PATCH for other sweeps. Templates save immediately; Pages and partials must be committed. Refuses existing unsaved work.\"\"\"\nimport os, sys\nsys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))\nimport trlib\nSITE = trlib.site_id()\nMATCH = lambda b: b.get(\"type\") == \"core/grid\" and (b.get(\"data\") or {}).get(\"last_row\") == \"center\"\nPATCH = {\"last_row\": \"start\"}\napply = \"--apply\" in sys.argv\nfor tgt in trlib.containers():\n tree = trlib.call(\"get_page_blocks\", {\"site_id\": SITE, \"target\": tgt})\n hits = [b for b in trlib.walk(tree.get(\"blocks\", tree) if isinstance(tree, dict) else tree) if MATCH(b)]\n if not hits:\n continue\n print(tgt[\"kind\"], tgt[\"id\"], len(hits))\n if apply:\n if tgt['kind'] != 'template' and trlib.call('read_working_copy', {'target': tgt}).get('working_copy'):\n raise RuntimeError('Unsaved work exists; review it before this sweep')\n for b in hits:\n trlib.call(\"update_block\", {\"site_id\": SITE, \"target\": tgt, \"block_id\": b[\"id\"], \"data\": PATCH})\n if tgt[\"kind\"] in (\"page\", \"partial\"):\n trlib.call(\"commit_working_copy\", {\"site_id\": SITE, \"target\": tgt})\n",
51
- "scripts/typeroll/trlib.py": "\"\"\"Small, version-bound MCP helper. Credentials are injected, never persisted.\nNo automatic retries for mutations. Run from the workspace root.\n\"\"\"\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nimport urllib.error\nimport urllib.parse\nimport urllib.request\n\n\nclass NoRedirect(urllib.request.HTTPRedirectHandler):\n def redirect_request(self, *args, **kwargs):\n return None # Never forward Authorization or signed PUTs to another URL.\n\n\ndef workspace():\n for directory in (Path.cwd(), *Path.cwd().parents):\n file = directory / 'typeroll.json'\n if file.is_file():\n value = json.loads(file.read_text())\n if not all(value.get(k) for k in ('portal', 'organization_id', 'site_id', 'version')):\n raise ValueError('Complete portal, organization_id, site_id and version in typeroll.json first')\n origin = urllib.parse.urlsplit(value['portal'])\n if origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):\n raise ValueError('portal must be a plain origin')\n if origin.scheme != 'https' and not (origin.scheme == 'http' and origin.hostname in ('localhost', '127.0.0.1', '::1')):\n raise ValueError('portal requires HTTPS (HTTP is allowed only on loopback)')\n return value\n raise ValueError('Run from a workspace containing typeroll.json')\n\n\nclass Client:\n def __init__(self, config=None):\n self.config = config or workspace()\n self.url = self.config['portal'].rstrip('/') + '/api/mcp?tools=full'\n self.session = None\n self.sequence = 0\n self.tools = None\n self.verified = False\n self.opener = urllib.request.build_opener(NoRedirect)\n\n def rpc(self, method, params=None, notify=False):\n self.sequence += 1\n body = {'jsonrpc': '2.0', 'method': method}\n if params is not None:\n body['params'] = params\n if not notify:\n body['id'] = self.sequence\n key = os.environ.get('TYPEROLL_API_KEY')\n if not key:\n raise ValueError('Inject TYPEROLL_API_KEY privately before running')\n headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream'}\n if self.session:\n headers['Mcp-Session-Id'] = self.session\n request = urllib.request.Request(self.url, data=json.dumps(body).encode(), headers=headers)\n try:\n with self.opener.open(request, timeout=180) as response:\n self.session = response.headers.get('Mcp-Session-Id') or self.session\n raw = response.read().decode()\n except urllib.error.HTTPError as error:\n raise RuntimeError(f'MCP HTTP {error.code}; no mutation was retried') from None\n if notify:\n return None\n result = decode_rpc(raw, self.sequence)\n if 'error' in result:\n raise RuntimeError(f\"MCP RPC error {result['error'].get('code')}\")\n return result['result']\n\n def initialize(self):\n self.rpc('initialize', {'protocolVersion': '2025-03-26', 'capabilities': {},\n 'clientInfo': {'name': 'typeroll-workspace', 'version': '1'}})\n self.rpc('notifications/initialized', notify=True)\n self.tools = {}\n cursor = None\n seen = set()\n while True:\n result = self.rpc('tools/list', {'cursor': cursor} if cursor else {})\n self.tools.update({t['name']: t for t in result['tools']})\n cursor = result.get('nextCursor')\n if not cursor:\n break\n if cursor in seen:\n raise RuntimeError('tools/list repeated its cursor')\n seen.add(cursor)\n\n def call(self, tool, args=None):\n if self.tools is None:\n self.initialize()\n if tool not in self.tools:\n raise ValueError(f'Tool unavailable: {tool}')\n if not self.verified:\n info = self.invoke('get_site', {})\n if info.get('organization_id') != self.config['organization_id'] or info.get('id') != self.config['site_id']:\n raise ValueError('Connected Organization/Site does not match typeroll.json; nothing was written')\n self.verified = True\n return self.invoke(tool, args or {})\n\n def invoke(self, tool, args):\n props = self.tools[tool]['inputSchema'].get('properties', {})\n data = dict(args)\n for key, wanted in [('site_id', self.config['site_id']), ('version', self.config['version'])]:\n if key in data and data[key] != wanted:\n raise ValueError(f'{key} differs from typeroll.json; change the binding explicitly')\n if key in props:\n data[key] = wanted\n if 'version' not in props and self.config['version'] != 'main' and tool != 'get_site':\n raise ValueError(f'{tool} has no version parameter; refusing an implicit main-version operation')\n result = self.rpc('tools/call', {'name': tool, 'arguments': data})\n if result.get('isError'):\n raise RuntimeError(f'{tool} failed; inspect through your authenticated MCP client (no retry)')\n if 'structuredContent' in result:\n return result['structuredContent']\n text = ''.join(c.get('text', '') for c in result.get('content', []) if c.get('type') == 'text')\n return json.loads(text)\n\n\ndef decode_rpc(raw, request_id):\n if raw.lstrip().startswith('{'):\n value = json.loads(raw)\n if value.get('id') != request_id:\n raise ValueError('MCP response did not contain the matching request ID')\n return value\n # Streamable HTTP may include notifications, multiline data, and keepalives.\n for event in raw.replace('\\r\\n', '\\n').split('\\n\\n'):\n data = '\\n'.join(line[5:].lstrip() for line in event.splitlines() if line.startswith('data:'))\n if data:\n value = json.loads(data)\n if value.get('id') == request_id:\n return value\n raise ValueError('MCP response did not contain the matching request ID')\n\n\n_client = None\n\n\ndef call(tool, args=None):\n global _client\n if _client is None:\n _client = Client()\n return _client.call(tool, args)\n\n\ndef site_id():\n return workspace()['site_id']\n\n\ndef walk(blocks):\n for block in blocks or []:\n yield block\n yield from walk(block.get('children'))\n for slot in block.get('slots') or []:\n yield from walk(slot)\n\n\ndef all_pages():\n cursor, seen = None, set()\n while True:\n args = {'limit': 200, **({'cursor': cursor} if cursor else {})}\n result = call('list_pages', args)\n yield from result['pages']\n cursor = result.get('next_cursor')\n if not cursor:\n return\n if cursor in seen:\n raise RuntimeError('list_pages repeated its cursor')\n seen.add(cursor)\n\n\ndef all_migration_urls():\n offset = 0\n while True:\n result = call('list_migration_urls', {'limit': 1000, 'offset': offset})\n rows = result['urls']\n yield from rows\n offset += len(rows)\n if not rows or offset >= result.get('total_matching', float('inf')):\n return\n\n\ndef containers():\n for page in all_pages():\n yield {'kind': 'page', 'id': page['id']}\n for kind, tool, key in [('template', 'list_page_templates', 'templates'), ('partial', 'list_partials', 'partials')]:\n result = call(tool)\n rows = result if isinstance(result, list) else result.get(key, result.get('page_templates'))\n if not isinstance(rows, list):\n raise ValueError(f'Unexpected response from {tool}')\n for row in rows:\n yield {'kind': kind, 'id': row['id']}\n\n\ndef upload(file, content_type, alt=''):\n content = Path(file).read_bytes()\n sha = hashlib.sha256(content).hexdigest()\n mint = call('create_upload_url', {'filename': Path(file).name, 'content_type': content_type,\n 'size': len(content), 'alt_text': alt})\n if urllib.parse.urlsplit(mint['upload_url']).scheme != 'https':\n raise ValueError('Signed upload must use HTTPS')\n request = urllib.request.Request(mint['upload_url'], data=content, method='PUT', headers={'Content-Type': content_type})\n try:\n with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response:\n response.read()\n except Exception:\n raise RuntimeError('Signed upload failed; URL withheld; no automatic retry') from None\n result = call('finalize_media', {'media_id': mint['media_id'], 'expected_sha256': sha})\n evidence = result.get('result', result)\n if evidence.get('sha256') != sha:\n raise ValueError('Finalize did not confirm the source SHA-256; do not reference this asset')\n return {'media_id': mint['media_id'], 'cdn_url': mint['cdn_url'], 'sha256': sha}\n",
53
+ "scripts/typeroll/trlib.py": "\"\"\"Small, version-bound MCP helper. Credentials are injected, never persisted.\nNo automatic retries for mutations. Run from the workspace root.\n\"\"\"\nimport hashlib\nimport json\nimport os\nfrom pathlib import Path\nimport urllib.error\nimport urllib.parse\nimport urllib.request\n\n\nclass NoRedirect(urllib.request.HTTPRedirectHandler):\n def redirect_request(self, *args, **kwargs):\n return None # Never forward Authorization or signed PUTs to another URL.\n\n\ndef workspace():\n for directory in (Path.cwd(), *Path.cwd().parents):\n file = directory / 'typeroll.json'\n if file.is_file():\n value = json.loads(file.read_text())\n if not all(value.get(k) for k in ('portal', 'organization_id', 'site_id', 'version')):\n raise ValueError('Complete portal, organization_id, site_id and version in typeroll.json first')\n origin = urllib.parse.urlsplit(value['portal'])\n if origin.username or origin.password or origin.query or origin.fragment or origin.path not in ('', '/'):\n raise ValueError('portal must be a plain origin')\n if origin.scheme != 'https' and not (origin.scheme == 'http' and origin.hostname in ('localhost', '127.0.0.1', '::1')):\n raise ValueError('portal requires HTTPS (HTTP is allowed only on loopback)')\n return value\n raise ValueError('Run from a workspace containing typeroll.json')\n\n\nclass Client:\n def __init__(self, config=None):\n self.config = config or workspace()\n self.url = self.config['portal'].rstrip('/') + '/api/mcp?tools=full'\n self.session = None\n self.sequence = 0\n self.tools = None\n self.verified = False\n self.opener = urllib.request.build_opener(NoRedirect)\n\n def rpc(self, method, params=None, notify=False):\n self.sequence += 1\n body = {'jsonrpc': '2.0', 'method': method}\n if params is not None:\n body['params'] = params\n if not notify:\n body['id'] = self.sequence\n key = os.environ.get('TYPEROLL_API_KEY')\n if not key:\n raise ValueError('Inject TYPEROLL_API_KEY privately before running')\n headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',\n 'Accept': 'application/json, text/event-stream'}\n if self.session:\n headers['Mcp-Session-Id'] = self.session\n request = urllib.request.Request(self.url, data=json.dumps(body).encode(), headers=headers)\n try:\n with self.opener.open(request, timeout=180) as response:\n self.session = response.headers.get('Mcp-Session-Id') or self.session\n raw = response.read().decode()\n except urllib.error.HTTPError as error:\n raise RuntimeError(f'MCP HTTP {error.code}; no mutation was retried') from None\n if notify:\n return None\n result = decode_rpc(raw, self.sequence)\n if 'error' in result:\n raise RuntimeError(f\"MCP RPC error {result['error'].get('code')}\")\n return result['result']\n\n def initialize(self):\n self.rpc('initialize', {'protocolVersion': '2025-03-26', 'capabilities': {},\n 'clientInfo': {'name': 'typeroll-workspace', 'version': '1'}})\n self.rpc('notifications/initialized', notify=True)\n self.tools = {}\n cursor = None\n seen = set()\n while True:\n result = self.rpc('tools/list', {'cursor': cursor} if cursor else {})\n self.tools.update({t['name']: t for t in result['tools']})\n cursor = result.get('nextCursor')\n if not cursor:\n break\n if cursor in seen:\n raise RuntimeError('tools/list repeated its cursor')\n seen.add(cursor)\n\n def call(self, tool, args=None):\n if self.tools is None:\n self.initialize()\n if tool not in self.tools:\n raise ValueError(f'Tool unavailable: {tool}')\n if not self.verified:\n info = self.invoke('get_site', {})\n if info.get('organization_id') != self.config['organization_id'] or info.get('id') != self.config['site_id']:\n raise ValueError('Connected Organization/Site does not match typeroll.json; nothing was written')\n self.verified = True\n return self.invoke(tool, args or {})\n\n def invoke(self, tool, args):\n props = self.tools[tool]['inputSchema'].get('properties', {})\n data = dict(args)\n for key, wanted in [('site_id', self.config['site_id']), ('version', self.config['version'])]:\n if key in data and data[key] != wanted:\n raise ValueError(f'{key} differs from typeroll.json; change the binding explicitly')\n if key in props:\n data[key] = wanted\n if 'version' not in props and self.config['version'] != 'main' and tool != 'get_site':\n raise ValueError(f'{tool} has no version parameter; refusing an implicit main-version operation')\n result = self.rpc('tools/call', {'name': tool, 'arguments': data})\n if result.get('isError'):\n raise RuntimeError(f'{tool} failed; inspect through your authenticated MCP client (no retry)')\n if 'structuredContent' in result:\n return result['structuredContent']\n text = ''.join(c.get('text', '') for c in result.get('content', []) if c.get('type') == 'text')\n return json.loads(text)\n\n\ndef decode_rpc(raw, request_id):\n if raw.lstrip().startswith('{'):\n value = json.loads(raw)\n if value.get('id') != request_id:\n raise ValueError('MCP response did not contain the matching request ID')\n return value\n # Streamable HTTP may include notifications, multiline data, and keepalives.\n for event in raw.replace('\\r\\n', '\\n').split('\\n\\n'):\n data = '\\n'.join(line[5:].lstrip() for line in event.splitlines() if line.startswith('data:'))\n if data:\n value = json.loads(data)\n if value.get('id') == request_id:\n return value\n raise ValueError('MCP response did not contain the matching request ID')\n\n\n_client = None\n\n\ndef call(tool, args=None):\n global _client\n if _client is None:\n _client = Client()\n return _client.call(tool, args)\n\n\ndef site_id():\n return workspace()['site_id']\n\n\ndef walk(blocks):\n for block in blocks or []:\n yield block\n yield from walk(block.get('children'))\n for slot in block.get('slots') or []:\n yield from walk(slot)\n\n\ndef all_pages(full=False):\n cursor, seen = None, set()\n while True:\n args = {'limit': 200, **({'full': True} if full else {}), **({'cursor': cursor} if cursor else {})}\n result = call('list_pages', args)\n yield from result['pages']\n cursor = result.get('next_cursor')\n if not cursor:\n return\n if cursor in seen:\n raise RuntimeError('list_pages repeated its cursor')\n seen.add(cursor)\n\n\ndef all_migration_urls():\n offset = 0\n while True:\n result = call('list_migration_urls', {'limit': 1000, 'offset': offset})\n rows = result['urls']\n yield from rows\n offset += len(rows)\n if not rows or offset >= result.get('total_matching', float('inf')):\n return\n\n\ndef containers():\n for page in all_pages():\n yield {'kind': 'page', 'id': page['id']}\n for kind, tool, key in [('template', 'list_page_templates', 'templates'), ('partial', 'list_partials', 'partials')]:\n result = call(tool)\n rows = result if isinstance(result, list) else result.get(key, result.get('page_templates'))\n if not isinstance(rows, list):\n raise ValueError(f'Unexpected response from {tool}')\n for row in rows:\n yield {'kind': kind, 'id': row['id']}\n\n\ndef upload(file, content_type, alt=''):\n content = Path(file).read_bytes()\n sha = hashlib.sha256(content).hexdigest()\n mint = call('create_upload_url', {'filename': Path(file).name, 'content_type': content_type,\n 'size': len(content), 'alt_text': alt})\n if urllib.parse.urlsplit(mint['upload_url']).scheme != 'https':\n raise ValueError('Signed upload must use HTTPS')\n request = urllib.request.Request(mint['upload_url'], data=content, method='PUT', headers={'Content-Type': content_type})\n try:\n with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response:\n response.read()\n except Exception:\n raise RuntimeError('Signed upload failed; URL withheld; no automatic retry') from None\n result = call('finalize_media', {'media_id': mint['media_id'], 'expected_sha256': sha})\n evidence = result.get('result', result)\n if evidence.get('sha256') != sha:\n raise ValueError('Finalize did not confirm the source SHA-256; do not reference this asset')\n return {'media_id': mint['media_id'], 'cdn_url': mint['cdn_url'], 'sha256': sha}\n",
52
54
  "workflows/design.md": "# Layout defaults and owner decisions\n\nUse current native composition starters and block schemas. These defaults are\nstarting points, not permission to overwrite an approved design.\n\n- Keep incomplete grid rows left-aligned (`last_row: start`). Never enlarge one\n peer card just to fill the gap. Center only on an explicit owner request.\n- Prefer `core/navigation_menu` with `core/navigation_links` desktop/mobile slots\n for a header drawer (below 1024 px). Read tr-header-footer; test 1023/1024 px.\n- Separate dense footer about text from the link grid. Give it a readable width\n and a larger gap; inspect 390/1024/1280/1536 px. Equal columns need equal density.\n- Sections own gutters. Do not erase side padding with `padding: 3rem 0` when\n only vertical spacing is intended. Wrap column-slot contents in a flow\n container with article rhythm when they need vertical block spacing.\n- Give cards one clear destination. Use whole_card_link without action_label\n or download actions. Check excerpt, image, whitespace and keyboard activation.\n- Do not fade body text with opacity. Verify actual text/background contrast,\n current links on light/dark navigation, and all states.\n- Preserve image subjects across widths. Choose contain/original sizing when\n cropping or upscaling is not intended. Hero crop, text placement, plates,\n corner treatment and category-page composition are project decisions in\n brief/brand.md, not a universal design.\n- After migration, check heading hierarchy, unchanged preserved text, HTML\n comments, links with ../, and the home SEO title/suffix. Shared headings and\n titles must not be duplicated in body content.\n\nRecord deliberate departures in decisions.md with scope and owner approval.\nUse a QA round per review: method, pages/viewports, expected differences, results,\nunverified items and remaining defects. Record platform findings in\nqa/typeroll-platform-feedback.md; do not bury them in workaround CSS.\n\n## Site tokens before block overrides\n\nCore 0.2.106 / template capabilities 0.54.0 adds palette names in all colour\nfields and named typography/spacing steps. Check the connected capability\nversion; on older renderers use `var(--color-surface)` rather than a bare name.\n\nSet colours and font families in site settings, then use named styles and\npalette names in blocks. Do not write hex, rgb or font-family literals in block\ncontent. Prefer `primary`, `secondary`, `accent`, `background`, `surface`, `text`,\n`text_light`, and `on_primary`/`on_secondary`/`on_accent` for text on brand colours.\nUse `surface_hover` for a darker interactive surface (derived automatically\nunless explicitly configured). Existing semantic roles should be reused; a\nnew visual shade is not automatically a new semantic role. For a derived shade\nuse `color-mix(in srgb, var(--color-surface) 92%, var(--color-primary))`.\nFor an additional role not in the native palette, define a named site style\nor a site CSS variable derived from existing palette variables, and document it.\n\nChoose `heading`, `body` or `mono` for fonts. Font families belong only in the\nsite's `fonts` settings. Use type-size steps `xs`, `sm`, `md`, `lg`, `xl`, `2xl`,\n`3xl`; spacing `none`, `xs`, `sm`, `md`, `lg`, `xl`; line height `tight`, `snug`,\n`normal`, `relaxed`, `loose`. Presentation fields accept responsive maps of these\nnames, for example `font_size_px: {mobile: \"md\", desktop: \"lg\"}`. Read the field\nschema: counts, widths and breakpoints are not spacing steps. Pixel values are\nexplicit exceptions, not the default authoring method. Configure scales in\n`design_scales`, then reuse them in role styles and blocks.\n\nRecord deliberate exceptions in `decisions.md`, with source/block, reason and\nreview criteria (for example white text on a photograph). Run `lint_design_tokens`\nbefore launch and after major design changes. It warns, never blocks launch.\nUse the starter's `scripts/typeroll/color_tokens.py` for an exact-match dry run;\nreview the plan before applying it in a dedicated Version. Equal hex values can\nmean different roles: never automatically choose between background and text.\nVerify a primary/surface change in a test Version across header, footer, cards,\nmenus and buttons, then restore the test settings. Do not republish automatically.\n",
53
55
  "workflows/interview.md": "# Guided website interview\n\n## When to start or resume\n\nUse this routine when the owner asks for guidance or a material decision is missing\nfrom the brief. Read the existing briefs, decisions and brief/interview.md first.\nDo not restart an interview or ask questions already answered. If the brief is\ncomplete, summarize it and continue with the relevant routine. This is an agent\nconversation, not a portal wizard or a new API workflow.\n\n## How to conduct it\n\n- Use the owner's language. Ask one short question at a time; offer concrete\n choices and allow a free answer or \"not sure\". Explain technical choices plainly.\n- Reuse explicit instructions already given. Distinguish confirmed answers,\n proposals and unknowns. Never treat silence as approval.\n- If an answer is unknown, suggest an option with its consequences. Research and\n reversible prototypes may continue; do not perform dependent CMS writes until\n the necessary decision is confirmed.\n- Save each answer and its source/date in brief/interview.md, and the approved\n decision in the relevant brief. Keep the next question and unresolved items so\n another session can resume. Do not store credentials, personal contact lists\n or raw private transcripts here. No MCP connection is needed to plan locally.\n- Reopen only affected questions when the owner changes direction. Record the\n change in decisions.md; do not silently replace confirmed rules.\n\n## Interview stages\n\n1. **Goal and scope** — New website or migration? Purpose, audience, language,\n desired visitor actions, source URL and what is in/out of scope.\n Save to brief/site.md and brief/migration.md when relevant.\n2. **Target and access** — Existing or new Typeroll Site? Discover accessible\n Organizations/Sites through MCP and confirm the exact target and Version before\n writes. Explain any missing storage or access when it becomes relevant; never\n ask for a secret in chat. Save non-secret IDs to typeroll.json only when known.\n3. **Text decision (migration)** — Preserve existing wording or renew it?\n Preserve covers headings, body copy, CTA labels, captions and other authored\n text. Record SEO titles/descriptions, alt text, legal text, translation and any\n page-specific exceptions explicitly. Renewal needs an agreed editorial brief;\n it is not permission to invent facts. Save text_intent in brief/migration.md.\n4. **Design and image decision (migration)** — Independently preserve or renew\n layout, typography, colors and imagery? Offer all four text/design combinations.\n For renewal, ask which existing assets must remain and which may be replaced\n or generated. Clarify cropping, logos, credits and image rights. Optimization\n alone must not change the motif. Save design_intent and asset exceptions in\n brief/migration.md, and the direction in brief/brand.md. A fresh design never\n grants permission to rewrite preserved text.\n5. **Content and behavior** — Page types, shared templates, navigation, forms,\n downloads, integrations and who will edit them. Identify missing capabilities\n before scaling up. Save to brief/content-model.md. New websites skip migration\n preservation choices and establish their brand/content brief here.\n6. **SEO and continuity** — Markets, search intent, useful topics and existing\n valuable URLs. Record evidence separately from hypotheses in brief/seo.md.\n Confirm metadata policy; text preservation cannot be bypassed as \"SEO cleanup\".\n Every old public URL must remain or redirect meaningfully regardless of the\n two migration choices. Retiring a URL requires a separate agreed exception.\n7. **Owner rules and review** — Non-negotiable copy/design constraints, editing\n roles and who approves prototypes, publication, DNS or messages. Save confirmed\n rules to brief/rules.md. Keep technical access separate from user authorization.\n8. **Recap and next action** — Show the scope, both migration decisions (if any),\n image exceptions, acceptance references, open questions and the next concrete\n step. Ask the owner to correct/confirm the brief before dependent writes.\n Reuse existing approval if it already covers that exact step. Brief approval\n alone does not authorize publication, domain cutover or mail.\n\n## Completion\n\nMark the interview ready for the agreed next step only when its blocking questions\nare answered; list deferred decisions explicitly. Continue with workflows/new-site.md\nor workflows/migration.md and load only the relevant MCP recipe. Confirm one\nprototype per distinct layout before bulk work. Review preserved dimensions\nagainst the source and renewed dimensions against the approved brief separately.\n",
54
- "workflows/migration.md": "# Migration kickoff\n\nIf guidance is needed, start or resume workflows/interview.md. Otherwise fill in\nthis prompt from the confirmed brief. The two preservation choices are independent.\n\n```text\nMigrate [source URL] to Typeroll, Organization [ID/name], Site [ID/name], Version [ID].\nText: [preserve existing wording / renew against an agreed editorial brief].\nDesign and images: [preserve / renew against an agreed design brief].\nRead AGENTS.md and use workflows/interview.md for missing decisions.\nRead tr-migration-evidence, then tr-migrate-wp (or the appropriate source recipe).\nConfirm the target and import readiness before writes. Inventory URLs, content,\nmedia, templates and integrations; record both choices and exceptions in brief/migration.md.\nBuild and verify one prototype per distinct layout before bulk reconstruction.\nUse standard blocks first, editable custom types next, isolated HTML only for\njustified exceptions. Rebuild imported HTML with block tools; a mode switch does not convert it.\nKeep preserved text unchanged even when the design is new. Record which images\nmay change. Preserve every old public URL or redirect to a relevant destination.\nProtect existing CMS edits. Review text and design/images against their own\nacceptance references, plus mobile/desktop behavior, downloads, forms and URL coverage.\nReport unverified items separately. Do not publish, switch DNS or send messages\nwithout explicit approval.\n```\n\nThe managed importer stages cleaned HTML, media and shared references in Review.\nThe external agent can then reconstruct those Pages; no manual editor conversion\nis required. An agent can also build block Pages directly from source material.\nNeither path includes an automatic HTML-to-blocks converter. Read and preserve the\nfull source before switching modes, then save/read back and preview the rebuilt\nPage on the selected Version. Never publish an empty intermediate block Page.\n\nTrack import, block reconstruction, separate text/design review, candidate\npublication and domain cutover as separate milestones. Import completion is not\nlaunch approval. All four text/design combinations use the same block hierarchy\nand URL continuity contract. Planning choices are not migration API fields.\n\n## Repeatable reconstruction and acceptance\n\nRead workflows/design.md and scripts/README.md. First check whether public\nWordPress REST + HTML covers the source; use tr-migrate-wp's access decision\ntable before asking for a helper plugin. Inventory both Pages and posts plus\ninternal links and known old slugs. A sitemap alone is not complete.\n\nDiscard HTML comments before extracting text, resolve relative links against\nthe source, and keep an explicit old URL map. Do not redirect deleted content\nto the home page merely to get a 200. Preserve/renew text and design separately.\nUse capture_text.cjs + compare_text.py for preserved text with explicit selectors.\n\nBefore every authorized test deployment run check_unsaved.py (Pages and partials).\nDeploy saved content to the agreed protected test host, then run check_url_parity.py,\ncheck_assets.py and shoot_hosted.cjs. Check whole-card targets with\ncard-stacking-check.cjs and inspect actual desktop/mobile screenshots. Record a\nQA round and platform findings. Review on the owner's phone when possible.\nDomain preparation/cutover and production indexing follow their own explicit\napproval scope and the platform's current guided flow; do not bypass readiness.\nDo not request a second approval for a step the owner has already authorized.\n\n\n## Native controls and publication checks\n\nOn Core 0.2.103 / MCP 0.45.79 or later, discover the actual block schemas first.\nUse column `content_gap_px`, responsive section `padding_y`, heading-role\n`space_before`/`space_after`, hero focal/fit/text-position fields and image\n`never_upscale` instead of one-off CSS. Responsive block keys start at `mobile`,\nnot `base`. For vertical-only spacing, do not reset horizontal padding with CSS\nshorthand. Optional `click_to_load` videos need an explicit poster if desired;\nchoose a first-party poster to avoid early provider requests.\n\n`import_sitemap` can include public WordPress REST (`include_wordpress: true`)\nand a bounded crawl (`crawl_pages: 10`). Record truncation and access warnings.\nA `matched_draft` URL is accounted for in the CMS but not ready for launch.\nResolve `unpublished_targets`, redirect loops/chains and actual deployed parity.\n\nA deploy with working copies returns `unsaved_content`; review the targets.\nSave explicitly or obtain approval to publish saved content only using\n`allow_unsaved: true`. Never silently save somebody else's work.\n\nKeep the bound Organization/Site/Version explicit. A site API key cannot switch\nto another Organization: use an authorized OAuth connection or a separate key.\nRead the schema for target envelopes and pagination; do not guess aliases.\nPage/partial block writes update working copies; check `saved` and use\n`commit_working_copy` with `target: {kind: \"page\"|\"partial\", id: \"...\"}`.\nPage templates save immediately and do not have working copies. REST read envelopes and\nMCP tool text are different layers; `call.py` returns the decoded tool result.\n\n## Design token check\n\nFollow workflows/design.md for palette/font roles and named scales. Run\n`lint_design_tokens` before launch and after major design changes. Review exact\ncolour replacements with scripts/typeroll/color_tokens.py; record deliberate\nexceptions in decisions.md. Preserve text independently from design changes.\n\n## Source retirement, analytics and domain preparation\n\nBefore old hosting is retired, follow workflows/source-media.md: preserve unused\nand print originals in a private archive with independently verified checksums.\nOnly approved web assets enter the CMS. Use ICC-aware conversion for CMYK/TIFF.\n\nEnable Analytics through the authorized Typeroll API/MCP (`update_app` or the\nOrganization analytics tools), then read back settings and verify the beacon and\ncookies on the actual artifact. Resolve missing provider scopes through the\nnormal approval flow. A separately authorized provider-side setup with a supplied\nbeacon token is an alternative, not a way to bypass Organization permissions.\nNever expose a provider API token as a public beacon token.\n\nFrom Core 0.2.106, saving future hosts does not redirect normal publication.\nKeep using `trigger_deploy` to update and review the current/temporary address.\nRead `next_publication` in `read_publishing_domains` for the destination.\nShortly before DNS changes, deliberately call `prepare_publishing_domain_change`\nwith the current revision. It prepares the last successful public snapshot,\nnot later unpublished CMS edits. Review its frozen indexing settings and DNS\nrequirements, then approve traffic separately. If another normal publication\nsucceeds while the candidate waits, prepare again: an outdated snapshot cannot\nreplace newer published content. There is no need to clear saved future hosts.\nTo correct candidate indexing, save the intended setting, publish normally, and\nprepare again. Temporary-host protection keeps the review address noindex even\nwhen the published authored setting permits indexing on the future host.\nKeep MX/SPF and unrelated DNS unchanged; review any validation window and obtain\nthe already-defined traffic-switch authorization.\n\n## Owner-supplied facts and photos\n\nRecord owner statements, source and confirmation date. Preserve approved copy\nunder `text: preserve`; do not silently delete dates/prices or rewrite it into\n\"timeless\" language. Under `text: renew`, propose removing stale details or using\nrecurring-event wording only when a source supports recurrence. Flag uncertain\nfacts for review or keep new Pages in draft; missing evidence does not prove that\nan event stopped. Link to the actual organiser when relevant. Upload approved\nphotos with SHA-256 verification and review focal points at mobile/desktop widths.\n",
56
+ "workflows/migration.md": "# Migration kickoff\n\nIf guidance is needed, start or resume workflows/interview.md. Otherwise fill in\nthis prompt from the confirmed brief. The two preservation choices are independent.\n\n```text\nMigrate [source URL] to Typeroll, Organization [ID/name], Site [ID/name], Version [ID].\nText: [preserve existing wording / renew against an agreed editorial brief].\nDesign and images: [preserve / renew against an agreed design brief].\nRead AGENTS.md and use workflows/interview.md for missing decisions.\nRead tr-migration-evidence, then tr-migrate-wp (or the appropriate source recipe).\nConfirm the target and import readiness before writes. Inventory URLs, content,\nmedia, templates and integrations; record both choices and exceptions in brief/migration.md.\nBuild and verify one prototype per distinct layout before bulk reconstruction.\nUse standard blocks first, editable custom types next, isolated HTML only for\njustified exceptions. Rebuild imported HTML with block tools; a mode switch does not convert it.\nKeep preserved text unchanged even when the design is new. Record which images\nmay change. Preserve every old public URL or redirect to a relevant destination.\nProtect existing CMS edits. Review text and design/images against their own\nacceptance references, plus mobile/desktop behavior, downloads, forms and URL coverage.\nReport unverified items separately. Do not publish, switch DNS or send messages\nwithout explicit approval.\n```\n\nThe managed importer stages cleaned HTML, media and shared references in Review.\nThe external agent can then reconstruct those Pages; no manual editor conversion\nis required. An agent can also build block Pages directly from source material.\nNeither path includes an automatic HTML-to-blocks converter. Read and preserve the\nfull source before switching modes, then save/read back and preview the rebuilt\nPage on the selected Version. Never publish an empty intermediate block Page.\n\nTrack import, block reconstruction, separate text/design review, candidate\npublication and domain cutover as separate milestones. Import completion is not\nlaunch approval. All four text/design combinations use the same block hierarchy\nand URL continuity contract. Planning choices are not migration API fields.\n\n## Repeatable reconstruction and acceptance\n\nRead workflows/design.md and scripts/README.md. First check whether public\nWordPress REST + HTML covers the source; use tr-migrate-wp's access decision\ntable before asking for a helper plugin. Inventory both Pages and posts plus\ninternal links and known old slugs. A sitemap alone is not complete.\n\nDiscard HTML comments before extracting text, resolve relative links against\nthe source, and keep an explicit old URL map. Do not redirect deleted content\nto the home page merely to get a 200. Preserve/renew text and design separately.\nUse capture_text.cjs + compare_text.py for preserved text with explicit selectors.\n\nBefore every authorized test deployment run check_unsaved.py (Pages and partials).\nDeploy saved content to the agreed protected test host, then run check_url_parity.py,\ncheck_assets.py and shoot_hosted.cjs. Check whole-card targets with\ncard-stacking-check.cjs and inspect actual desktop/mobile screenshots. Record a\nQA round and platform findings. Review on the owner's phone when possible.\nDomain preparation/cutover and production indexing follow their own explicit\napproval scope and the platform's current guided flow; do not bypass readiness.\nDo not request a second approval for a step the owner has already authorized.\n\n\n## Native controls and publication checks\n\nOn Core 0.2.103 / MCP 0.45.79 or later, discover the actual block schemas first.\nUse column `content_gap_px`, responsive section `padding_y`, heading-role\n`space_before`/`space_after`, hero focal/fit/text-position fields and image\n`never_upscale` instead of one-off CSS. Responsive block keys start at `mobile`,\nnot `base`. For vertical-only spacing, do not reset horizontal padding with CSS\nshorthand. Optional `click_to_load` videos need an explicit poster if desired;\nchoose a first-party poster to avoid early provider requests.\n\n`import_sitemap` can include public WordPress REST (`include_wordpress: true`)\nand a bounded crawl (`crawl_pages: 10`). Record truncation and access warnings.\nA `matched_draft` URL is accounted for in the CMS but not ready for launch.\nResolve `unpublished_targets`, redirect loops/chains and actual deployed parity.\n\nA deploy with working copies returns `unsaved_content`; review the targets.\nSave explicitly or obtain approval to publish saved content only using\n`allow_unsaved: true`. Never silently save somebody else's work.\n\nKeep the bound Organization/Site/Version explicit. A site API key cannot switch\nto another Organization: use an authorized OAuth connection or a separate key.\nRead the schema for target envelopes and pagination; do not guess aliases.\nPage/partial block writes update working copies; check `saved` and use\n`commit_working_copy` with `target: {kind: \"page\"|\"partial\", id: \"...\"}`.\nPage templates save immediately and do not have working copies. REST read envelopes and\nMCP tool text are different layers; `call.py` returns the decoded tool result.\n\n## Design token check\n\nFollow workflows/design.md for palette/font roles and named scales. Run\n`lint_design_tokens` before launch and after major design changes. Review exact\ncolour replacements with scripts/typeroll/color_tokens.py; record deliberate\nexceptions in decisions.md. Preserve text independently from design changes.\n\n## Source retirement, analytics and domain preparation\n\nBefore old hosting is retired, follow workflows/source-media.md: preserve unused\nand print originals in a private archive with independently verified checksums.\nOnly approved web assets enter the CMS. Use ICC-aware conversion for CMYK/TIFF.\n\nEnable Analytics through the authorized Typeroll API/MCP (`update_app` or the\nOrganization analytics tools), then read back settings and verify the beacon and\ncookies on the actual artifact. Resolve missing provider scopes through the\nnormal approval flow. A separately authorized provider-side setup with a supplied\nbeacon token is an alternative, not a way to bypass Organization permissions.\nNever expose a provider API token as a public beacon token.\n\nFrom Core 0.2.106, saving future hosts does not redirect normal publication.\nKeep using `trigger_deploy` to update and review the current/temporary address.\nRead `next_publication` in `read_publishing_domains` for the destination.\nShortly before DNS changes, deliberately call `prepare_publishing_domain_change`\nwith the current revision. It prepares the last successful public snapshot,\nnot later unpublished CMS edits. Review its frozen indexing settings and DNS\nrequirements, then approve traffic separately. If another normal publication\nsucceeds while the candidate waits, prepare again: an outdated snapshot cannot\nreplace newer published content. There is no need to clear saved future hosts.\nTo correct candidate indexing, save the intended setting, publish normally, and\nprepare again. Temporary-host protection keeps the review address noindex even\nwhen the published authored setting permits indexing on the future host.\nKeep MX/SPF and unrelated DNS unchanged; review any validation window and obtain\nthe already-defined traffic-switch authorization.\n\n## Owner-supplied facts and photos\n\nRecord owner statements, source and confirmation date. Preserve approved copy\nunder `text: preserve`; do not silently delete dates/prices or rewrite it into\n\"timeless\" language. Under `text: renew`, propose removing stale details or using\nrecurring-event wording only when a source supports recurrence. Flag uncertain\nfacts for review or keep new Pages in draft; missing evidence does not prove that\nan event stopped. Link to the actual organiser when relevant. Upload approved\nphotos with SHA-256 verification and review focal points at mobile/desktop widths.\n\n## Internal Page notes\n\nFollow workflows/page-notes.md and pages/README.md. Create notes during migration,\nread before edits, update afterward, check external links at each editorial review,\nand archive notes for confirmed removed Pages under pages/archived/. Refreshing\ngenerated facts never counts as a completed review. Keep notes out of public output.\n",
55
57
  "workflows/new-site.md": "# New website kickoff\n\nUse this prompt only when the brief is already clear. Otherwise start or resume\nworkflows/interview.md; the owner does not need to fill in every document alone.\n\n```text\nCreate [website] for [audience and purpose] in Typeroll Organization [ID/name].\nRead AGENTS.md, confirm the Site and Version, and use workflows/interview.md for\nmissing decisions. Record confirmed goals, brand, content model, owner rules and\nSEO strategy in brief/. Read tr-new-site and relevant tool schemas on demand.\nBuild representative desktop/mobile layouts with editable blocks and shared templates.\nShare previews, record accepted decisions and keep unresolved items explicit.\nDo not publish, change DNS or send messages without explicit approval.\n```\n\nThe CMS owns current content and settings. The repository owns the brief, rules,\ndecisions and evidence. Load recipes when needed, not the whole manual.\n\n## Design token check\n\nFollow workflows/design.md for palette/font roles and named scales. Run\n`lint_design_tokens` before launch and after major design changes. Review exact\ncolour replacements with scripts/typeroll/color_tokens.py; record deliberate\nexceptions in decisions.md. Preserve text independently from design changes.\n",
58
+ "workflows/page-notes.md": "# Page notes and editorial review\n\n1. During migration or creation, create pages/<page-id>.md using pages/README.md.\n Record sources, preservation choices, owner decisions and open questions.\n2. Before editing a Page, read its note and relevant decisions.md entries. Missing\n notes mean missing context, not permission to invent facts. Read current CMS\n content too; do not overwrite newer edits from repository snapshots.\n3. After an authorized edit, append a dated change/decision/comment entry, update\n sources and open questions, and refresh generated facts with page_notes.py.\n Saving or refreshing is not editorial review and must not advance last_checked.\n4. At each review, inspect the current rendered Page and follow every external\n link, including template links. Verify destination and relevance, not just HTTP\n status. Record redirects, failures and uncertain checks; propose corrections\n instead of inventing replacements. Record actual review date and next review.\n5. For a confirmed removed Page, log deletion, replacement/redirect and archive\n location, then move its note into pages/archived/. Keep its history in Git.\n\nUse index.md as a review queue; no scheduler or mail is enabled by this routine.\nPublication and DNS changes still require their normal authorization. These notes\nare repository-only; no new MCP Page-notes fields or portal storage are implied.\n",
56
59
  "workflows/source-media.md": "# Preserve source media before retirement\n\nThis is a separate owner-approved archive, not an import of every old file into\nthe CMS. Do not delete source files or cancel old hosting as part of this routine.\n\n1. Inventory originals and downloads from the source API/export, including files\n absent from published pages. Include drafts, private records and historical\n attachments when authorized. A public sitemap or REST response cannot prove\n the library is complete. Keep the export under ignored `sources/private/`.\n2. Compare with the new site's references, including templates, partials and\n retained URLs. Record used, unused and uncertain files. \"Unused\" is not an\n instruction to delete. Preserve print originals even when a web derivative exists.\n3. Agree a private archive destination, its owning account, retention and access.\n Verify account/bucket identity and that public bucket/custom-domain access is\n disabled. Use the owner's credentials through their secret manager; never put\n credentials in scripts or manifests. Do not provision or expose a bucket silently.\n4. Copy with bounded concurrency and bounded reads, stream large files and use\n resumable transfers when supported. Preserve a collision-safe key derived from\n the full source host/path, not just the filename. Never overwrite a different\n object merely because the basename matches.\n5. Record source URL, destination key, source ID, byte count, SHA-256, media type,\n title/alt/caption, copy time and verification state in a private manifest.\n ETag is not universally MD5 (multipart objects and providers differ). Use a\n documented checksum API or stream the destination back and compare SHA-256\n and size. Metadata claiming a hash is not proof of the uploaded bytes.\n6. Resume only verified matches; report every unavailable source and hash conflict.\n Verify a restore from the archive, including a large file. Keep an independent\n private copy of the manifest. The owner reviews completeness and grants a\n separate old-host retirement decision. This archive does not preserve old\n public URLs by itself: keep URL parity/redirect acceptance separate.\n\n## Prepare print images for the web\n\nUse `scripts/typeroll/prepare_image.py` for a reviewed local TIFF or other original.\nInstall Pillow with ImageCms in an isolated environment. It applies EXIF\norientation, converts through the embedded (or explicitly supplied) ICC profile\nto sRGB, embeds the sRGB profile, strips source EXIF metadata and writes a new\nJPEG. It never changes the original, uploads files or edits a Page.\n\n```sh\npython3 scripts/typeroll/prepare_image.py sources/private/original.tif sources/private/web.jpg\n```\n\nCMYK without the correct profile fails; obtain the actual source profile instead\nof guessing. `--source-profile sources/private/original.icc` supplies it.\nUnprofiled RGB/gray requires an explicit `--assume-srgb` decision. Multi-page,\nanimated and transparent images require a separate reviewed workflow. Compare\ncolour, subject framing and sharpness visually before upload. Upload through the\nnormal integrity-checked media flow and choose alt/caption text independently.\nStore the tool's hash report privately; never commit customer images or archives.\n"
57
60
  };
58
61
  export const BUNDLED_DOCS = {
@@ -17,7 +17,7 @@ export function compactTools(catalog) {
17
17
  }),
18
18
  local('describe_tool', 'Read one tool’s complete description, effect and JSON input schema before calling it.', { name: z.string().max(100) }, async (args) => {
19
19
  const tool = byName.get(String(args.name));
20
- return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).meta({ additionalProperties: false }), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
20
+ return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).strict(), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
21
21
  }),
22
22
  ...['read', 'write', 'admin'].map(effect => ({
23
23
  name: `call_${effect}_tool`, effect,
@@ -28,7 +28,7 @@ export function compactTools(catalog) {
28
28
  if (!tool || tool.effect !== effect)
29
29
  return fail(new Error(`This endpoint accepts only ${effect} tools. Use describe_tool to check the required effect.`));
30
30
  try {
31
- return await tool.invoke(z.object(tool.schema).parse(args.arguments));
31
+ return await tool.invoke(z.object(tool.schema).strict().parse(args.arguments));
32
32
  }
33
33
  catch (error) {
34
34
  return fail(error);
package/dist/server.js CHANGED
@@ -327,7 +327,7 @@ export function buildServer(options) {
327
327
  }
328
328
  for (const tool of options.toolMode === 'compact' ? compactTools(catalog) : catalog) {
329
329
  register(tool.name, {
330
- description: tool.description, inputSchema: z.object(tool.schema).meta({ additionalProperties: false }),
330
+ description: tool.description, inputSchema: z.object(tool.schema).strict(),
331
331
  annotations: { readOnlyHint: tool.effect === 'read', destructiveHint: tool.effect !== 'read', openWorldHint: true },
332
332
  }, tool.invoke);
333
333
  }
@@ -40,7 +40,7 @@ export const settingsTools = [
40
40
  {
41
41
  name: 'update_site_settings',
42
42
  description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Accepts everything the portal Settings form does. Requires admin permission on the site, as in the portal. Only the fields you pass change. ' +
43
- 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
43
+ 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" or "patch" key. ' +
44
44
  'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
45
45
  'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
46
46
  'Unknown top-level keys return a 400 error listing the valid fields. ' +
@@ -118,6 +118,8 @@ export const settingsTools = [
118
118
  },
119
119
  handler: withErrorBoundary(async (args, { client, siteId }) => {
120
120
  const { version, ...body } = args;
121
+ if (!Object.keys(body).some(key => body[key] !== undefined))
122
+ throw new Error('No settings supplied. Pass at least one setting at the top level, for example {tagline: "New tagline"}; do not wrap fields in patch or settings.');
121
123
  const res = await client.patch(siteId, 'settings', body, v(version));
122
124
  return ok(res);
123
125
  }),
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.45.81';
11
+ export const VERSION = '0.45.82';
package/dist/workspace.js CHANGED
@@ -51,6 +51,8 @@ Public documentation: https://typeroll.com/docs/llms.txt
51
51
  ## Edit and verify
52
52
 
53
53
  - Read current Pages, Content types, templates, partials and relevant block schemas.
54
+ - Read pages/ notes before editing; follow workflows/page-notes.md for sources,
55
+ dated logs, external-link review and archiving. Keep internal notes out of public output.
54
56
  - Use native editable blocks; document necessary exceptions and design decisions.
55
57
  - Configure palette/font roles and design scales in site settings. Read workflows/design.md
56
58
  before styling blocks; run lint_design_tokens and document hardcoded exceptions.
@@ -115,7 +117,7 @@ export const workspaceTemplates = {
115
117
  'brief/content-model.md': '# Content model\n\n## Page types and routes\n\n## Fields and references\n\n## Shared templates and partials\n\n## Taxonomy and navigation\n\nUse the current CMS schema. This document describes decisions, not a second schema store.\n',
116
118
  'brief/rules.md': "# Owner preferences and project rules\n\n## Confirmed rules\n\nRecord each rule, its scope, who approved it and the date. Separate confirmed rules from proposals.\n\n## Writing and factual accuracy\n\nVoice, terminology, prohibited claims, source requirements and legal copy that must not be rewritten.\n\n## Design and editing\n\nBrand constraints, accessibility, editable content, standard/custom blocks and documented HTML exceptions.\n\n## Operations and approval\n\nWho may approve content, publication, DNS changes and messages? Credentials stay outside this repository.\n\n## Exceptions and superseded rules\n\nLink decisions.md; do not silently override a confirmed rule. Project notes do not grant permissions beyond the user's authorization or account access.\n",
117
119
  'brief/seo.md': "# SEO strategy\n\n## Business goals, audience, markets and languages\n\n## Search intent and topic coverage\n\nRecord research sources and dates. Distinguish evidence from assumptions; do not invent traffic or ranking forecasts.\n\n## Page roles and URL plan\n\nContent types, useful landing pages, taxonomy, internal links and routes to preserve or redirect. Avoid near-duplicate pages created only for keyword variants.\n\n## Indexing and canonical policy\n\nWhich pages should be indexable, public noindex, or private? Record deliberate canonical overrides and sitemap policy; actual values live in the CMS.\n\n## Editorial standards and structured data\n\nUnique useful copy, supported claims, descriptive titles and links, accessible image text, schema matching visible content. No fixed title length or word count is a hard SEO rule.\n\n## Measurement and review\n\nBaseline, analytics/consent decisions, measures of success and review dates. Never store credentials or private analytics exports here.\n",
118
- 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md and the relevant Page | tr-edit-site |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
120
+ 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md, pages/ notes and the relevant Page | tr-edit-site |\n| Page notes and review | workflows/page-notes.md, pages/README.md, pages/index.md | Read before edits; update logs afterward |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
119
121
  'decisions.md': '# Decisions\n\nRecord date, decision, reason, approved scope and alternatives when useful. For design-token exceptions include the source/block, fixed value, reason (e.g. white text on a photograph) and review criteria.\n',
120
122
  'sources/README.md': '# Sources\n\nStore research references and copy proposals. Record source URLs and dates.\nPrivate extracts belong in ignored private/ or an approved external store.\nDo not treat local drafts as current CMS content or commit media libraries.\n',
121
123
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.81",
3
+ "version": "0.45.82",
4
4
  "description": "Typeroll CMS MCP server \u2013 connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -19,7 +19,11 @@ description: Edit or create Typeroll pages with a person’s usual AI assistant.
19
19
  content types, or `search_pages` for words in saved content. Ask the user to
20
20
  choose when several pages fit their description. A different host requires
21
21
  confirming a site switch and selecting a new context; never silently switch.
22
- 4. Read the page and relevant Page template, block types and reusable sections.
22
+ 4. When a project repository is available, read its pages/<page-id>.md note and
23
+ relevant decisions before editing. Follow workflows/page-notes.md: update the
24
+ dated log afterward, check external links during reviews, and archive notes
25
+ for confirmed removed Pages. Do not store private notes in public CMS fields.
26
+ Read the page and relevant Page template, block types and reusable sections.
23
27
  Prefer existing native blocks and shared styles. Editors may create custom
24
28
  block types and shared Page templates when needed. Read `tr-custom-blocks`
25
29
  or `tr-page-template` only when that work is relevant. Before changing a
@@ -0,0 +1,40 @@
1
+ # Internal Page notes
2
+
3
+ Keep internal sources, decisions, comments and dated history in this repository,
4
+ not in public CMS fields. The CMS remains the source of current content; notes
5
+ are editorial context, never a second content copy. Keep this workspace private
6
+ if notes contain private information. Never put credentials or signed URLs here.
7
+ Do not include pages/ in static output, content imports or public source bundles.
8
+
9
+ One file per stable Page ID: pages/<encoded-page-id>.md. The helper percent-encodes
10
+ IDs that contain path characters. URLs and titles can change without renaming notes.
11
+ Use a separate workspace/Git branch for another Site or Version; the generated
12
+ facts are bound to portal, Organization, Site and Version and reject mismatches.
13
+
14
+ Each note has review, next_review (YYYY-MM or YYYY-MM-DD), last_checked and
15
+ responsible metadata, followed by Purpose, Sources (including date and whether an
16
+ owner statement), Publishing considerations, Open questions and Log. Use dated
17
+ change, decision, comment and check entries; keep the newest first. Site-wide
18
+ rules belong in decisions.md, referenced from the relevant notes.
19
+
20
+ Choose a review cadence for the project, for example quarterly, annual, event-led
21
+ or when source facts change. There is no default season or automatic review date.
22
+ Refreshing facts never changes last_checked or next_review. Set those only after
23
+ an actual review. index.md sorts scheduled reviews first, then unscheduled Pages.
24
+
25
+ The generated block contains saved CMS metadata and candidate links from the saved
26
+ body. It does not inspect template/partial links, JavaScript or final rendering;
27
+ query-bearing URLs are omitted to avoid copying credentials. Check the rendered
28
+ Page and every external link during review. Sources, historical import statistics
29
+ and their dates belong in the human sections, not invented by the script.
30
+
31
+ Run scripts/typeroll/page_notes.py --dry-run, inspect, then omit --dry-run to refresh.
32
+ --migration-inventory optionally includes recorded redirect sources. Run from the
33
+ workspace root with a privately injected TYPEROLL_API_KEY. No CMS writes occur.
34
+ Only the generated markers and index.md are refreshed; malformed markers fail
35
+ without overwriting notes. Reconcile existing notes manually before adopting the
36
+ new marker/scope format; do not replace an established project's editorial history.
37
+
38
+ Notes without a Page are reported, never deleted automatically. Confirm deletion
39
+ and any redirect, add a final log entry, then move the note to pages/archived/.
40
+ Do not archive just because an API call failed or access changed.
@@ -50,3 +50,10 @@ indexing have distinct effects but do not require repeated approval if covered.
50
50
  updates, future-host candidates and explicit traffic approval.
51
51
  - [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.
52
52
  - [ ] Review owner facts and dates against the chosen preserve/renew text policy.
53
+
54
+ ## Internal Page notes
55
+
56
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
57
+ read before edits, update afterward, check external links at each editorial review,
58
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
59
+ generated facts never counts as a completed review. Keep notes out of public output.
@@ -109,3 +109,10 @@ uploading or changing CMS content. Missing CMYK profiles, transparency, multiple
109
109
  frames and existing output files stop with an error. `--assume-srgb` is only for
110
110
  reviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.
111
111
  See workflows/source-media.md for archive verification and original preservation.
112
+
113
+ ## Internal Page notes
114
+
115
+ `page_notes.py --dry-run` previews local note creation/fact refresh and index updates.
116
+ Omit --dry-run to write local files; --migration-inventory adds recorded redirect
117
+ sources. It never writes to the CMS, marks a Page reviewed or deletes notes. Read
118
+ pages/README.md and workflows/page-notes.md before first use.
@@ -0,0 +1,144 @@
1
+ """Refresh repository-only Page facts; never edit CMS content or human notes.
2
+
3
+ Run from the workspace root. --dry-run previews local changes. Optional
4
+ --migration-inventory adds saved migration URL facts. Credentials use trlib.
5
+ """
6
+ import argparse
7
+ from datetime import date
8
+ import hashlib
9
+ from html.parser import HTMLParser
10
+ import json
11
+ from pathlib import Path
12
+ import re
13
+ from urllib.parse import quote, urlsplit
14
+ import trlib
15
+
16
+ START = '<!-- generated:start (page_notes.py; do not edit by hand) -->'
17
+ END = '<!-- generated:end -->'
18
+
19
+
20
+ def filename(page_id):
21
+ if not isinstance(page_id, str) or not page_id or page_id.lower() in ('readme', 'index', 'archived', '.', '..'):
22
+ raise ValueError('Page ID is empty or conflicts with a reserved notes path')
23
+ return quote(page_id, safe='-_') + '.md'
24
+
25
+
26
+ def cell(value):
27
+ return str(value if value is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('|', '&#124;').replace('\n', ' ').replace('\r', ' ')
28
+
29
+
30
+ class Links(HTMLParser):
31
+ def __init__(self):
32
+ super().__init__()
33
+ self.urls = set()
34
+
35
+ def handle_starttag(self, tag, attrs):
36
+ if tag == 'a':
37
+ self.urls.update(v for k, v in attrs if k == 'href' and v)
38
+
39
+
40
+ def body_links(page, blocks):
41
+ parser = Links()
42
+ parser.feed(page.get('html_content') or '')
43
+
44
+ def visit(value, key=''):
45
+ if isinstance(value, dict):
46
+ for k, v in value.items():
47
+ visit(v, k)
48
+ elif isinstance(value, list):
49
+ for v in value:
50
+ visit(v, key)
51
+ elif isinstance(value, str):
52
+ if key in ('href', 'link', 'url', 'button_url', 'link_url'):
53
+ parser.urls.add(value)
54
+ if '<a' in value.lower():
55
+ parser.feed(value)
56
+ for block in trlib.walk(blocks):
57
+ visit(block.get('data') or {})
58
+ # Do not persist signed preview links or query credentials in repository notes.
59
+ return sorted(u for u in parser.urls if not urlsplit(u).query and not urlsplit(u).username
60
+ and urlsplit(u).scheme in ('', 'http', 'https'))
61
+
62
+
63
+ def scope(config):
64
+ return hashlib.sha256(json.dumps([config[k] for k in ('portal', 'organization_id', 'site_id', 'version')]).encode()).hexdigest()
65
+
66
+
67
+ def refresh(root, config, pages, links, migration=(), dry_run=False):
68
+ """Read all inputs before writing. Preserve every byte outside owned markers."""
69
+ out = root / 'pages'
70
+ if out.is_symlink():
71
+ raise ValueError('The notes directory must not be a symlink')
72
+ names = [filename(p['id']) for p in pages]
73
+ if len({n.lower() for n in names}) != len(names):
74
+ raise ValueError('Page IDs collide on a case-insensitive filesystem')
75
+ identity = '<!-- scope: ' + scope(config) + ' -->'
76
+ planned, rows = {}, []
77
+ for p, name in zip(pages, names):
78
+ file = out / name
79
+ if file.is_symlink():
80
+ raise ValueError('A note must not be a symlink')
81
+ text = file.read_text() if file.exists() else (
82
+ '---\nreview: unset\nnext_review: \nlast_checked: \nresponsible: \n---\n\n# ' + cell(p['title']) + '\n\n'
83
+ + START + '\n' + identity + '\n' + END
84
+ + '\n\n## Purpose\n\nTODO\n\n## Sources\n\nTODO\n\n## Publishing considerations\n\nTODO\n\n'
85
+ '## Open questions\n\n- Set a review cadence and next review date.\n\n## Log\n\n- '
86
+ + date.today().isoformat() + ' · comment · Note created; editorial review has not been performed.\n')
87
+ if text.count(START) != 1 or text.count(END) != 1 or text.index(START) >= text.index(END):
88
+ raise ValueError('Missing or ambiguous generated markers in ' + name + '; reconcile manually')
89
+ old = text.split(START)[1].split(END)[0]
90
+ if identity not in old:
91
+ raise ValueError('Note scope differs from the selected Organization/Site/Version: ' + name)
92
+ facts = [START, identity, '', '| Fact | Value |', '| --- | --- |']
93
+ for label, value in [('Page ID', p['id']), ('URL', p.get('url')), ('Title', p['title']),
94
+ ('Status', p.get('status')), ('Content type', p.get('content_type')),
95
+ ('Template', p.get('template')), ('Last saved', p.get('date_updated'))]:
96
+ facts.append('| ' + label + ' | ' + cell(value) + ' |')
97
+ aliases = [m['path'] for m in migration if m.get('status') == 'redirected' and m.get('target', '').rstrip('/') == p.get('url', '').rstrip('/')]
98
+ if aliases:
99
+ facts.append('| Recorded old URLs | ' + cell(', '.join(sorted(aliases))) + ' |')
100
+ facts += ['', 'Saved body link candidates (not a complete rendered-link audit; query URLs omitted):', '']
101
+ facts += ['- ' + cell(u) for u in links.get(p['id'], [])] or ['- None found.']
102
+ facts += ['', END]
103
+ new = text[:text.index(START)] + '\n'.join(facts) + text[text.index(END) + len(END):]
104
+ if new != text:
105
+ planned[file] = new
106
+ elif not file.exists():
107
+ planned[file] = new
108
+ fm = dict(re.findall(r'^(review|next_review|last_checked|responsible):[ \t]*([^\n]*)', new.split('---', 2)[1], re.M)) if new.startswith('---\n') else {}
109
+ nxt = fm.get('next_review', '').strip()
110
+ if nxt and not re.fullmatch(r'\d{4}-\d{2}(?:-\d{2})?', nxt):
111
+ raise ValueError('Use YYYY-MM or YYYY-MM-DD for next_review in ' + name)
112
+ rows.append((nxt or '9999', name, '| ' + (nxt or 'Not scheduled') + ' | [' + cell(p['title']).replace('[', '&#91;').replace(']', '&#93;') + '](' + name + ') | ' + cell(fm.get('review', 'unset')) + ' | ' + cell(fm.get('last_checked', '')) + ' |'))
113
+ index = out / 'index.md'
114
+ if index.is_symlink():
115
+ raise ValueError('The notes index must not be a symlink')
116
+ index_text = '# Page notes index\n\nGenerated from local notes; dates are editorial decisions, not CMS save dates.\n\n| Next review | Page | Cadence | Last checked |\n| --- | --- | --- | --- |\n' + '\n'.join(r[2] for r in sorted(rows)) + '\n'
117
+ if not index.exists() or index.read_text() != index_text:
118
+ planned[index] = index_text
119
+ orphans = sorted(f.name for f in out.glob('*.md') if f.name not in {*names, 'README.md', 'index.md'})
120
+ if not dry_run:
121
+ out.mkdir(parents=True, exist_ok=True)
122
+ for file, text in planned.items():
123
+ file.write_text(text)
124
+ return {'changed': [str(f.relative_to(root)) for f in planned], 'notes_without_page': orphans, 'dry_run': dry_run}
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument('--dry-run', action='store_true')
130
+ parser.add_argument('--migration-inventory', action='store_true')
131
+ args = parser.parse_args()
132
+ root = Path.cwd()
133
+ if not (root / 'typeroll.json').is_file():
134
+ parser.error('Run from the workspace root containing typeroll.json')
135
+ config = trlib.workspace()
136
+ # list_pages(full=True) reads saved content, unlike read_page's draft overlay.
137
+ pages = list(trlib.all_pages(full=True))
138
+ links = {p['id']: body_links(p, p.get('blocks') or []) for p in pages}
139
+ migration = list(trlib.all_migration_urls()) if args.migration_inventory else []
140
+ print(json.dumps(refresh(root, config, pages, links, migration, args.dry_run), indent=2))
141
+
142
+
143
+ if __name__ == '__main__':
144
+ main()
@@ -155,10 +155,10 @@ def walk(blocks):
155
155
  yield from walk(slot)
156
156
 
157
157
 
158
- def all_pages():
158
+ def all_pages(full=False):
159
159
  cursor, seen = None, set()
160
160
  while True:
161
- args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
161
+ args = {'limit': 200, **({'full': True} if full else {}), **({'cursor': cursor} if cursor else {})}
162
162
  result = call('list_pages', args)
163
163
  yield from result['pages']
164
164
  cursor = result.get('next_cursor')
@@ -127,3 +127,10 @@ recurring-event wording only when a source supports recurrence. Flag uncertain
127
127
  facts for review or keep new Pages in draft; missing evidence does not prove that
128
128
  an event stopped. Link to the actual organiser when relevant. Upload approved
129
129
  photos with SHA-256 verification and review focal points at mobile/desktop widths.
130
+
131
+ ## Internal Page notes
132
+
133
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
134
+ read before edits, update afterward, check external links at each editorial review,
135
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
136
+ generated facts never counts as a completed review. Keep notes out of public output.
@@ -0,0 +1,20 @@
1
+ # Page notes and editorial review
2
+
3
+ 1. During migration or creation, create pages/<page-id>.md using pages/README.md.
4
+ Record sources, preservation choices, owner decisions and open questions.
5
+ 2. Before editing a Page, read its note and relevant decisions.md entries. Missing
6
+ notes mean missing context, not permission to invent facts. Read current CMS
7
+ content too; do not overwrite newer edits from repository snapshots.
8
+ 3. After an authorized edit, append a dated change/decision/comment entry, update
9
+ sources and open questions, and refresh generated facts with page_notes.py.
10
+ Saving or refreshing is not editorial review and must not advance last_checked.
11
+ 4. At each review, inspect the current rendered Page and follow every external
12
+ link, including template links. Verify destination and relevance, not just HTTP
13
+ status. Record redirects, failures and uncertain checks; propose corrections
14
+ instead of inventing replacements. Record actual review date and next review.
15
+ 5. For a confirmed removed Page, log deletion, replacement/redirect and archive
16
+ location, then move its note into pages/archived/. Keep its history in Git.
17
+
18
+ Use index.md as a review queue; no scheduler or mail is enabled by this routine.
19
+ Publication and DNS changes still require their normal authorization. These notes
20
+ are repository-only; no new MCP Page-notes fields or portal storage are implied.