@typeroll/mcp-server 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,32 @@ import { ok, withErrorBoundary, versionParam } from './helpers.js';
5
5
  function v(version) {
6
6
  return version ? { version } : undefined;
7
7
  }
8
+ // The per-block markup. These three fields dominate the payload size (a few
9
+ // dozen blocks of template HTML + scoped CSS can exceed an agent's token
10
+ // budget and get truncated), and an agent enumerating the library rarely
11
+ // needs them — read_block_type fetches them for one block on demand. So
12
+ // list_block_types omits them by default and returns a lightweight summary.
13
+ const HEAVY_BLOCK_TYPE_FIELDS = ['template', 'styles', 'script'];
14
+ /** Strip the heavy markup fields from each block type in a `block-types`
15
+ * response, preserving the field `schema` and all light metadata. Defensive:
16
+ * if the shape isn't the expected `{ block_types: [...] }`, pass it through. */
17
+ function summariseBlockTypes(res) {
18
+ if (!res || typeof res !== 'object')
19
+ return res;
20
+ const envelope = res;
21
+ const list = envelope.block_types;
22
+ if (!Array.isArray(list))
23
+ return res;
24
+ const slim = list.map((bt) => {
25
+ if (!bt || typeof bt !== 'object')
26
+ return bt;
27
+ const copy = { ...bt };
28
+ for (const field of HEAVY_BLOCK_TYPE_FIELDS)
29
+ delete copy[field];
30
+ return copy;
31
+ });
32
+ return { ...envelope, block_types: slim };
33
+ }
8
34
  export const blockTypeTools = [
9
35
  {
10
36
  name: 'export_block_types',
@@ -44,9 +70,13 @@ export const blockTypeTools = [
44
70
  },
45
71
  {
46
72
  name: 'list_block_types',
47
- description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), custom blocks (origin: "user", created in the portal), and third-party blocks (origin: "third_party", imported from .tcblocks packages). Each entry includes id, label, category, container/slot info, origin, and the full schema (fields with name/type/label/required/options/default). Call this FIRST when starting block-mode worknever hardcode block ids; the available set is per-site.',
73
+ description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), custom blocks (origin: "user", created in the portal), and third-party blocks (origin: "third_party", imported from .tcblocks packages). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site. By default this is a LIGHTWEIGHT SUMMARY: each entry has id, label, category, icon, container/slot info, origin, and the full field schema (name/type/label/required/options/default) everything you need to pick a block and call add_block but NOT the render-time markup. The per-block template HTML, scoped styles, and script are omitted so the list stays within token budget no matter how large the library grows; fetch them for a single block with read_block_type, or pass full:true to inline them for every block (can be very large).',
48
74
  inputSchema: {
49
75
  include_core: z.boolean().optional().describe('Default true. Set false to get only custom + third-party (rarely needed).'),
76
+ full: z
77
+ .boolean()
78
+ .optional()
79
+ .describe("Default false. When true, include each block type's template HTML, styles CSS, and script inline — the full doc. Large; prefer read_block_type for one block's markup."),
50
80
  version: versionParam,
51
81
  },
52
82
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -56,7 +86,7 @@ export const blockTypeTools = [
56
86
  if (args.include_core === false)
57
87
  query.include_core = 'false';
58
88
  const res = await client.get(siteId, 'block-types', query);
59
- return ok(res);
89
+ return ok(args.full ? res : summariseBlockTypes(res));
60
90
  }),
61
91
  },
62
92
  {
@@ -1,32 +1,34 @@
1
1
  // Skill-discovery tools — the self-describing playbook layer.
2
2
  //
3
- // The package bundles a set of `tr-*.md` skill recipes under `skills/`. Until
4
- // now they were only useful if the operator happened to copy them into
5
- // `.claude/skills/` (see install-skills.ts) the running MCP server never
6
- // advertised them, so an agent had no way to learn that e.g. `tr-new-site`
7
- // exists. These two tools fix that by exposing the bundled skills directly
8
- // on the tool surface, identically over stdio and the hosted HTTP transport.
3
+ // The package bundles a set of `tr-*.md` skill recipes under `skills/` plus an
4
+ // AGENTS.md operating guide at the package root. These tools expose that
5
+ // content directly on the tool surface so an agent discovers the platform's
6
+ // playbook at connection time over stdio AND the hosted HTTP transport.
9
7
  //
10
- // Both are pure LOCAL file reads: they do NOT touch the REST API and need
11
- // neither TYPEROLL_API_KEY nor TYPEROLL_API_URL nor a site context. That's
12
- // why they're flagged `noSite: true`in multi-site (hosted) mode the
13
- // server skips the usual `site_id` injection/validation for them.
14
- import { promises as fs } from 'node:fs';
15
- import path from 'node:path';
8
+ // IMPORTANT: the content is read from the EMBEDDED module (bundled-content.ts,
9
+ // generated by scripts/embed-content.mjs), NOT from disk. The hosted endpoint
10
+ // runs inside the portal, which bundles this package after bundling a runtime
11
+ // `fs.readFile('../skills/…')` resolves into the portal's build output and
12
+ // fails with ENOENT, so disk reads crash list_skills/read_skill/read_guide on
13
+ // hosted. Embedded string lookups survive bundling. (The install-skills/init
14
+ // CLI still copies the physical files — but that only runs locally, where the
15
+ // files exist.)
16
+ //
17
+ // All three tools are pure in-memory lookups: no REST API, no API key, no site
18
+ // context — hence `noSite: true` (multi-site mode skips site_id for them).
16
19
  import { z } from 'zod';
17
- import { skillsDir } from '../install-skills.js';
20
+ import { BUNDLED_SKILLS, BUNDLED_DOCS } from '../bundled-content.js';
18
21
  import { ok, withErrorBoundary } from './helpers.js';
19
- /** The only basenames `read_skill` will resolve. Matches the install-skills
20
- * copy filter (`tr-*.md`) and, because it forbids slashes and dots, is also
21
- * the primary defence against path traversal. */
22
+ /** The only names `read_skill` will resolve. Matches the `tr-*.md` convention
23
+ * and, by forbidding slashes/dots, doubles as input hygiene. */
22
24
  export const SKILL_NAME_RE = /^tr-[a-z0-9-]+$/;
23
25
  /**
24
26
  * Pull `name:` / `description:` out of a skill file's YAML frontmatter.
25
27
  *
26
28
  * The frontmatter is a flat block we author by hand (two single-line keys),
27
29
  * so a deliberately tiny regex parse beats pulling in a YAML dependency.
28
- * Falls back to the filename-derived name when `name:` is absent and to an
29
- * empty description when `description:` is.
30
+ * Falls back to the given name when `name:` is absent and to an empty
31
+ * description when `description:` is.
30
32
  */
31
33
  export function parseSkillFrontmatter(markdown, fallbackName) {
32
34
  let name = fallbackName;
@@ -43,62 +45,63 @@ export function parseSkillFrontmatter(markdown, fallbackName) {
43
45
  }
44
46
  return { name, description };
45
47
  }
46
- /** List the bundled skills (name + description) from `sourceDir`. Pure read;
47
- * `sourceDir` defaults to the bundled `skills/` dir but is injectable for
48
- * tests. */
49
- export async function listBundledSkills(sourceDir = skillsDir()) {
50
- const entries = await fs.readdir(sourceDir);
51
- const files = entries.filter((f) => f.startsWith('tr-') && f.endsWith('.md')).sort();
52
- const skills = [];
53
- for (const file of files) {
54
- const raw = await fs.readFile(path.join(sourceDir, file), 'utf8');
55
- skills.push(parseSkillFrontmatter(raw, file.replace(/\.md$/, '')));
56
- }
57
- return skills;
48
+ /** List the bundled skills (name + description) from the embedded content. */
49
+ export function listBundledSkills() {
50
+ return Object.keys(BUNDLED_SKILLS)
51
+ .sort()
52
+ .map((key) => parseSkillFrontmatter(BUNDLED_SKILLS[key], key));
58
53
  }
59
- /** Read one bundled skill's markdown by name. Rejects anything that isn't a
60
- * `tr-<kebab>` basename and, belt-and-suspenders, verifies the resolved path
61
- * stays inside `sourceDir`. */
62
- export async function readBundledSkill(name, sourceDir = skillsDir()) {
54
+ /** Return one bundled skill's markdown by name. Rejects anything that isn't a
55
+ * `tr-<kebab>` name or isn't a known skill. */
56
+ export function readBundledSkill(name) {
63
57
  if (!SKILL_NAME_RE.test(name)) {
64
58
  throw new Error(`invalid skill name "${name}" — expected tr-<kebab-case>, e.g. tr-new-site. Use list_skills to see available skills.`);
65
59
  }
66
- const root = path.resolve(sourceDir);
67
- const target = path.resolve(root, `${name}.md`);
68
- // The regex already blocks `/` and `.`, so traversal can't get here — but
69
- // confirm the join landed exactly where we expect before touching disk.
70
- if (path.dirname(target) !== root) {
71
- throw new Error(`refusing to read outside the skills directory: ${name}`);
72
- }
73
- try {
74
- return await fs.readFile(target, 'utf8');
75
- }
76
- catch {
60
+ const content = BUNDLED_SKILLS[name];
61
+ if (content === undefined) {
77
62
  throw new Error(`skill "${name}" not found. Use list_skills to see available skills.`);
78
63
  }
64
+ return content;
65
+ }
66
+ /** Return a bundled package doc by whitelisted key (`agents` | `readme`). */
67
+ export function readPackageDoc(key) {
68
+ const content = BUNDLED_DOCS[key];
69
+ if (content === undefined) {
70
+ throw new Error(`unknown guide "${key}". Available: ${Object.keys(BUNDLED_DOCS).join(', ')}.`);
71
+ }
72
+ return content;
79
73
  }
80
74
  export const skillTools = [
75
+ {
76
+ name: 'read_guide',
77
+ description: "Return the full Typeroll agent guide (the bundled AGENTS.md): platform conventions, the data model in 90 seconds, common operations with worked recipes, safety boundaries, and the tool-family reference. Read it once at the start of a session for the complete operating context — especially on the hosted connector, where the file isn't on disk for the client to fold in. Pass doc='readme' for the package README instead. Pure in-memory read: no API key or site required.",
78
+ inputSchema: {
79
+ doc: z
80
+ .enum(['agents', 'readme'])
81
+ .optional()
82
+ .describe("Which guide to return: 'agents' (default — the full operating briefing) or 'readme'."),
83
+ },
84
+ noSite: true,
85
+ handler: withErrorBoundary(async (args) => ok(readPackageDoc(args.doc ?? 'agents'))),
86
+ },
81
87
  {
82
88
  name: 'list_skills',
83
- description: 'List the bundled Typeroll playbook skills (name + description). Call this EARLY — the moment the user wants to build, migrate, redesign, brand, or otherwise design a site — to discover the step-by-step recipe that fits (e.g. tr-new-site, tr-migrate-wp, tr-brand, tr-blog), then read_skill the most relevant one before acting. Pure local read: no API key and no site required, so it works on the hosted connector and stdio alike.',
89
+ description: 'List the bundled Typeroll playbook skills (name + description). Call this EARLY — the moment the user wants to build, migrate, redesign, brand, or otherwise design a site — to discover the step-by-step recipe that fits (e.g. tr-new-site, tr-migrate-wp, tr-brand, tr-blog, tr-responsive), then read_skill the most relevant one before acting. Pure in-memory read: no API key and no site required, so it works on the hosted connector and stdio alike.',
84
90
  noSite: true,
85
91
  handler: withErrorBoundary(async () => {
86
- const skills = await listBundledSkills();
92
+ const skills = listBundledSkills();
87
93
  return ok({ skills, count: skills.length });
88
94
  }),
89
95
  },
90
96
  {
91
97
  name: 'read_skill',
92
- description: 'Return the full markdown of one bundled skill by name (e.g. "tr-new-site"). Use it after list_skills to load the playbook for the task at hand. Pure local read: no API key and no site required.',
98
+ description: 'Return the full markdown of one bundled skill by name (e.g. "tr-new-site"). Use it after list_skills to load the playbook for the task at hand. Pure in-memory read: no API key and no site required.',
93
99
  inputSchema: {
94
100
  name: z
95
101
  .string()
96
102
  .describe('Skill name without the .md extension, e.g. "tr-new-site". Must match /^tr-[a-z0-9-]+$/.'),
97
103
  },
98
104
  noSite: true,
99
- handler: withErrorBoundary(async (args) => {
100
- const content = await readBundledSkill(args.name);
101
- return ok(content);
102
- }),
105
+ handler: withErrorBoundary(async (args) => ok(readBundledSkill(args.name))),
103
106
  },
104
107
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.21.0",
3
+ "version": "0.24.0",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,8 @@
28
28
  "skills"
29
29
  ],
30
30
  "scripts": {
31
+ "gen": "node scripts/embed-content.mjs",
32
+ "prebuild": "node scripts/embed-content.mjs",
31
33
  "build": "tsc -p .",
32
34
  "typecheck": "tsc --noEmit",
33
35
  "test": "vitest run",
@@ -0,0 +1,174 @@
1
+ ---
2
+ name: tr-header-footer
3
+ description: Vetted, robust header and footer presets to drop into the header/footer partials. Use when building or restyling a site's site-wide header or footer — start from a preset and restyle it instead of hand-rolling layout + overflow (the usual source of clipped logos and broken mobile menus).
4
+ ---
5
+
6
+ # Header & footer presets
7
+
8
+ Headers and footers are the two partials every page shows, and hand-rolling them
9
+ is where logos get clipped and mobile menus break. **Start from a preset below,
10
+ fill the placeholders, restyle with the site's colours — don't build the layout
11
+ from scratch.** Each preset is deliberately robust; the "why" notes call out the
12
+ traps it avoids.
13
+
14
+ ## How to use
15
+
16
+ 1. `read_site_settings` — grab `logo`, `site_name`, `tagline`, `contact.email`,
17
+ and the colour palette.
18
+ 2. `read_partial partial_id="header"` (and `footer`) — see what's already there;
19
+ don't blow away a working one without reason.
20
+ 3. Pick a preset, replace every `{{PLACEHOLDER}}`, adjust colours to the palette
21
+ (the presets already read `--color-*` / `--font-heading` with fallbacks).
22
+ 4. `update_partial partial_id="header" patch={ html_content: "…" } version="…"`.
23
+ 5. **Preview and self-review in context** (see `tr-redesign-branch` step 6):
24
+ the logo must be FULLY VISIBLE (not clipped), legible against its background,
25
+ and the mobile layout must work at 390px. Screenshot the header region in
26
+ context — never the logo element in isolation (that hides clipping).
27
+
28
+ Placeholders: `{{SITE_NAME}}`, `{{LOGO_URL}}`, `{{TAGLINE}}`, `{{EMAIL}}`, `{{YEAR}}`.
29
+
30
+ ---
31
+
32
+ ## Header A — Centered logo (minimal; landing pages)
33
+
34
+ ```html
35
+ <header class="tr-hdr tr-hdr--center">
36
+ <a class="tr-hdr-logo" href="/" aria-label="{{SITE_NAME}} — till startsidan">
37
+ <img src="{{LOGO_URL}}" alt="{{SITE_NAME}}" />
38
+ </a>
39
+ </header>
40
+ <style>
41
+ .tr-hdr--center{background:var(--color-surface,#fff);display:flex;justify-content:center;padding:clamp(1rem,2.5vw,1.6rem) 1.5rem}
42
+ .tr-hdr-logo{display:inline-block;line-height:0;transition:transform .15s ease}
43
+ .tr-hdr-logo:hover{transform:translateY(-1px)}
44
+ .tr-hdr-logo img{height:clamp(40px,6vw,58px);width:auto;display:block}
45
+ </style>
46
+ ```
47
+
48
+ **Why it's robust:** no `overflow:hidden` anywhere near the logo (the #1 cause of a
49
+ clipped wordmark); the logo sizes by `height` with `width:auto` so it never
50
+ distorts and never gets cropped; symmetric padding so it can't collide with the
51
+ section below. If you want a tinted header, set a solid `background` — don't add a
52
+ glow that has to be clipped.
53
+
54
+ ## Header B — Logo left + links right (no-JS responsive menu)
55
+
56
+ ```html
57
+ <header class="tr-hdr tr-hdr--nav">
58
+ <div class="tr-hdr-inner">
59
+ <a class="tr-hdr-logo" href="/" aria-label="{{SITE_NAME}} — till startsidan">
60
+ <img src="{{LOGO_URL}}" alt="{{SITE_NAME}}" />
61
+ </a>
62
+ <input type="checkbox" id="tr-nav-toggle" class="tr-nav-toggle" aria-hidden="true" />
63
+ <label for="tr-nav-toggle" class="tr-nav-burger" aria-label="Meny"><span></span><span></span><span></span></label>
64
+ <nav class="tr-hdr-nav" aria-label="Huvudmeny">
65
+ <a href="/">Start</a>
66
+ <a href="#">Sidan ett</a>
67
+ <a href="#">Sidan två</a>
68
+ <a class="tr-hdr-cta" href="#kontakt">Kontakta oss</a>
69
+ </nav>
70
+ </div>
71
+ </header>
72
+ <style>
73
+ .tr-hdr--nav{background:var(--color-surface,#fff);border-bottom:1px solid rgba(0,0,0,.06)}
74
+ .tr-hdr-inner{max-width:1160px;margin:0 auto;padding:.9rem 1.5rem;display:flex;align-items:center;justify-content:space-between;gap:1rem;flex-wrap:wrap}
75
+ .tr-hdr-logo{line-height:0}
76
+ .tr-hdr-logo img{height:clamp(36px,4.6vw,50px);width:auto;display:block}
77
+ .tr-hdr-nav{display:flex;align-items:center;gap:clamp(1rem,2.4vw,2rem);font-family:var(--font-heading),sans-serif;font-weight:600}
78
+ .tr-hdr-nav a{color:var(--color-text,#1a1a1a);text-decoration:none}
79
+ .tr-hdr-nav a:hover{color:var(--color-primary,#1F4FB8)}
80
+ .tr-hdr-cta{background:var(--color-primary,#1F4FB8);color:var(--color-primary-fg,#fff);padding:.6rem 1.2rem;border-radius:999px}
81
+ .tr-hdr-cta:hover{filter:brightness(1.05);color:var(--color-primary-fg,#fff)}
82
+ .tr-nav-toggle{display:none}
83
+ .tr-nav-burger{display:none;flex-direction:column;gap:5px;cursor:pointer;padding:.4rem}
84
+ .tr-nav-burger span{width:24px;height:2px;background:var(--color-text,#1a1a1a);border-radius:2px}
85
+ @media(max-width:760px){
86
+ .tr-nav-burger{display:flex}
87
+ .tr-hdr-nav{flex-basis:100%;flex-direction:column;align-items:stretch;gap:.2rem;max-height:0;overflow:hidden;transition:max-height .25s ease}
88
+ .tr-hdr-nav a{padding:.7rem .2rem}
89
+ .tr-nav-toggle:checked ~ .tr-hdr-nav{max-height:60vh}
90
+ }
91
+ </style>
92
+ ```
93
+
94
+ **Why it's robust:** the mobile menu is a pure-CSS checkbox toggle — no JS to break,
95
+ no library. The `overflow:hidden` is ONLY on the collapsing nav list (never on the
96
+ header or the logo), so the logo is always fully visible. Links use site colour
97
+ variables so it matches the brand automatically. The header wraps (`flex-wrap`) so
98
+ nothing overflows the viewport on narrow screens.
99
+
100
+ ---
101
+
102
+ ## Footer A — Centered minimal
103
+
104
+ ```html
105
+ <footer class="tr-ftr tr-ftr--center">
106
+ <div class="tr-ftr-inner">
107
+ <div class="tr-ftr-brand">{{SITE_NAME}}</div>
108
+ <p class="tr-ftr-tag">{{TAGLINE}}</p>
109
+ <p class="tr-ftr-contact"><a href="mailto:{{EMAIL}}">{{EMAIL}}</a></p>
110
+ <p class="tr-ftr-copy">© {{YEAR}} {{SITE_NAME}}</p>
111
+ </div>
112
+ </footer>
113
+ <style>
114
+ .tr-ftr--center{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}
115
+ .tr-ftr--center .tr-ftr-inner{max-width:1120px;margin:0 auto;padding:2.6rem 1.5rem;text-align:center;display:grid;gap:.45rem}
116
+ .tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff}
117
+ .tr-ftr-tag{margin:0;font-size:1rem;color:rgba(255,255,255,.85)}
118
+ .tr-ftr-contact{margin:.15rem 0 0}
119
+ .tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}
120
+ .tr-ftr-contact a:hover{text-decoration:underline}
121
+ .tr-ftr-copy{margin:.8rem 0 0;font-size:.85rem;color:rgba(255,255,255,.55)}
122
+ </style>
123
+ ```
124
+
125
+ ## Footer B — Three columns (brand · links · contact)
126
+
127
+ ```html
128
+ <footer class="tr-ftr tr-ftr--cols">
129
+ <div class="tr-ftr-grid">
130
+ <div class="tr-ftr-col">
131
+ <div class="tr-ftr-brand">{{SITE_NAME}}</div>
132
+ <p class="tr-ftr-tag">{{TAGLINE}}</p>
133
+ </div>
134
+ <nav class="tr-ftr-col" aria-label="Sidfot">
135
+ <a href="/">Start</a>
136
+ <a href="#">Sidan ett</a>
137
+ <a href="#">Sidan två</a>
138
+ </nav>
139
+ <div class="tr-ftr-col">
140
+ <p class="tr-ftr-contact"><a href="mailto:{{EMAIL}}">{{EMAIL}}</a></p>
141
+ </div>
142
+ </div>
143
+ <p class="tr-ftr-copy">© {{YEAR}} {{SITE_NAME}}</p>
144
+ </footer>
145
+ <style>
146
+ .tr-ftr--cols{background:var(--color-primary,#163C8C);color:rgba(255,255,255,.78)}
147
+ .tr-ftr--cols .tr-ftr-grid{max-width:1120px;margin:0 auto;padding:3rem 1.5rem 1.4rem;display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:2rem}
148
+ .tr-ftr--cols .tr-ftr-brand{font-family:var(--font-heading),sans-serif;font-weight:800;font-size:1.35rem;color:#fff;margin-bottom:.4rem}
149
+ .tr-ftr--cols .tr-ftr-tag{margin:0;color:rgba(255,255,255,.8);max-width:34ch}
150
+ .tr-ftr--cols .tr-ftr-col{display:grid;gap:.5rem;align-content:start}
151
+ .tr-ftr--cols nav a{color:rgba(255,255,255,.85);text-decoration:none}
152
+ .tr-ftr--cols nav a:hover{color:#fff;text-decoration:underline}
153
+ .tr-ftr-contact a{color:#fff;text-decoration:none;font-weight:600}
154
+ .tr-ftr--cols .tr-ftr-copy{max-width:1120px;margin:0 auto;padding:0 1.5rem 2.4rem;font-size:.85rem;color:rgba(255,255,255,.55)}
155
+ @media(max-width:680px){.tr-ftr--cols .tr-ftr-grid{grid-template-columns:1fr;gap:1.4rem}}
156
+ </style>
157
+ ```
158
+
159
+ **Why these footers are robust:** the columns collapse to one at 680px (no
160
+ horizontal scroll); all colours come from `--color-*` with fallbacks; the contact
161
+ is a real `mailto:` link; nothing relies on fixed heights. Swap `--color-primary`
162
+ for a custom dark if the brand's primary is too light for white text.
163
+
164
+ ---
165
+
166
+ ## Restyling notes
167
+
168
+ - The logo always comes from `read_site_settings → logo`. If it's `null`, set it
169
+ first (upload + `update_site_settings`) — don't hard-code a path.
170
+ - For a **shaped transition** from the header/footer into the page, don't build a
171
+ wave band by hand — that belongs to the adjacent `core/section` via its
172
+ `divider_top` / `divider_bottom` (see `tr-redesign-branch`).
173
+ - Keep the brand mark + a way home. Even a dramatic redesign keeps the logo
174
+ linking to `/`.
@@ -70,6 +70,13 @@ different lockup.
70
70
 
71
71
  ### 3. Header + footer partials
72
72
 
73
+ **Start from a vetted preset — don't hand-roll the layout.** `read_skill
74
+ tr-header-footer` has robust header + footer presets (centered logo, logo+nav
75
+ with a no-JS mobile menu, centered + 3-column footers) that avoid the usual
76
+ traps: clipped logos (no `overflow:hidden` near the logo), distorted logos
77
+ (`height` + `width:auto`), and broken mobile menus. Fill the placeholders and
78
+ restyle to the palette.
79
+
73
80
  Partials are usually simplest in HTML mode (one nav, a few links — no
74
81
  per-field editing needed). Keep them lean; literal site name (no template
75
82
  engine in partials):
@@ -219,6 +226,17 @@ get_preview_link # signed URL for browser review
219
226
  get_page_preview page_id="home" # rendered HTML for structural checks
220
227
  ```
221
228
 
229
+ **Self-review the visuals before you call it done — appearance AND
230
+ readability, not just structure.** Screenshot the deployed/preview site at
231
+ desktop (~1440px) and mobile (~390px) and look: logo FULLY VISIBLE (not clipped by
232
+ a header's overflow:hidden) + legible + brand-compliant against its actual
233
+ background — screenshot the header IN CONTEXT, not the logo element in isolation
234
+ (an element shot hides layout clipping); a light wordmark must not sit bare on a
235
+ light surface. Text contrast everywhere, no horizontal scroll or mid-word
236
+ breaks, every image rendered, mobile layout actually collapsed. "No overflow +
237
+ copy present" is not a design review — never report a build as done/perfect off
238
+ structural metrics alone.
239
+
222
240
  Share the preview link. Iterate on feedback with `update_block` /
223
241
  `add_block` / `move_block` — that's the point of block mode: surgical
224
242
  edits, not full-page rewrites.
@@ -9,6 +9,36 @@ Site-wide changes are exactly where copy-on-write branches earn their
9
9
  keep. This skill enforces the discipline: every redesign happens on a
10
10
  branch, preview-checked end-to-end, merged only after user sign-off.
11
11
 
12
+ **Copy comes from the LIVE page, not a local draft.** A redesign changes
13
+ the design, not the words. Read the existing copy from the live page
14
+ (`read_page`/`batch_read_pages`) and carry it over verbatim. Local
15
+ `sources/*.md` files are drafts — use them only if the user explicitly
16
+ says "apply the copy in `<file>`". Don't invent new headlines, drop
17
+ sections, or "restore" text from an old draft; a draft that had drifted
18
+ from the live page once sent a whole redesign off the approved wording.
19
+ When you must change a word, change it on the live page too and keep the
20
+ draft file in sync.
21
+
22
+ **Restraint beats decoration.** Default to clean, purposeful design. Don't reach
23
+ for decorative motifs (suns, blobs, glows, mascots, confetti) to look "graphic" —
24
+ unless a motif *means something for this brand/page*, it reads as random, and it's
25
+ usually the exact thing that clips, seams, and crops. These fragile patterns broke
26
+ a real build — avoid them:
27
+ - **A shape divider (wave/curve) between two sections** → don't hand-roll it in
28
+ `core/html`; a separate stacked shape seams against the next section (a Chrome
29
+ sub-pixel hairline). Use `core/section`'s **`divider_top` / `divider_bottom`**
30
+ (`wave | curve | tilt`) — the platform paints it in the section's own colour and
31
+ overlaps the neighbour by 1px, so it's seam-free by construction. Put the divider
32
+ on the section whose colour should rise/dip into the neighbour.
33
+ - **A glow/decoration inside an `overflow:hidden` box** → clipped to a hard edge.
34
+ Put it in a non-clipped layer, or size it to fade out before the box edge.
35
+ - **`object-fit:cover` on a portrait inside a circle/frame** → crops heads and
36
+ faces. Use `contain`, reframe the source art, or size the frame to the art.
37
+ - **A gradient "fade" at a section join** → reads as the design being cut off.
38
+ Make transitions deliberate (a clean shape or a solid edge), never a fade.
39
+ These are invisible in a small full-page thumbnail and only show at real size in
40
+ the actual browser — see the review gate in step 6.
41
+
12
42
  ## Recipe
13
43
 
14
44
  ### 1. Discover (always)
@@ -92,7 +122,36 @@ bulk_replace_text pattern="OldCo" replacement="NewCo" dry_run=false version="<br
92
122
 
93
123
  ### 6. Approval round
94
124
 
95
- Send the user a final preview link:
125
+ **First, self-review the visuals appearance AND readability.** Structural
126
+ checks (copy present, no overflow, images return 200) are NOT a design review
127
+ and must never be reported as "approved". Screenshot the deployed branch at
128
+ desktop (~1440px) AND mobile (~390px) and actually look:
129
+
130
+ - **Logo & brand marks:** fully visible (not cut off by a header's
131
+ `overflow:hidden` + an overlap/negative margin), legible with real contrast
132
+ against their *actual* background, and brand-compliant — e.g. a light/yellow
133
+ wordmark must not sit bare on a light surface (give it its plate/backing).
134
+ Screenshot the rendered HEADER REGION **in page context** — never the logo
135
+ element in isolation: an element screenshot renders the full SVG and hides
136
+ layout clipping, so a logo that's cut in half on the page looks perfect.
137
+ - **Contrast & readability:** every text-on-background pairing (headings, body,
138
+ buttons, cards on colored bands), not just the obvious ones.
139
+ - **Layout:** no horizontal scroll (`scrollWidth === clientWidth` at 360–390px),
140
+ no mid-word breaks, nothing clipped or overflowing, clean alignment / spacing /
141
+ hierarchy. Confirm every image rendered (scroll lazy ones into view first).
142
+ - **Both breakpoints:** a grid fine on desktop can fail to collapse on mobile
143
+ (see `tr-responsive` gotchas). Check mobile explicitly.
144
+ - **Decoration robustness, in the real browser:** every decorative shape / glow /
145
+ gradient is clean at real size in the actual target browser (Chrome) — no
146
+ hairline seam between a shape divider and its section, no glow clipped to a hard
147
+ edge, no faces cropped by a frame, no gradient fade-cutoff at a section join.
148
+ These don't show in a thumbnail (see "Restraint beats decoration" above).
149
+
150
+ Fix what you find and re-check before involving the user. "Looks structurally
151
+ fine" ≠ "looks good" — never tell the user a design is approved/perfect off
152
+ metrics alone.
153
+
154
+ Then send the user a final preview link:
96
155
 
97
156
  ```
98
157
  get_preview_link page_id=home version="<branch>" ttl_seconds=86400
@@ -0,0 +1,112 @@
1
+ ---
2
+ name: tr-responsive
3
+ description: Use when a layout must behave differently at different screen sizes — different grid columns per breakpoint, an icon-box that's icon-on-top on mobile but icon-left on tablet, hiding a block on small screens, fluid type. Triggers on "responsive", "mobile/tablet/desktop layout", "stack on mobile", "X columns on desktop and Y on mobile", "olika på mobil/surfplatta", "responsivt".
4
+ ---
5
+
6
+ # Make a Typeroll block layout responsive
7
+
8
+ Typeroll has a built-in five-breakpoint system. You almost never hand-write
9
+ media queries — you set per-breakpoint values on responsive fields and the
10
+ renderer compiles the `@media` rules per block instance.
11
+
12
+ ## The five breakpoints (mobile-first)
13
+
14
+ `mobile (<640) · tablet (≥640) · laptop (≥1024) · desktop (≥1280) · wide (≥1536)`
15
+
16
+ A responsive field takes either a scalar (applies everywhere) or a sparse
17
+ object `{ mobile?, tablet?, laptop?, desktop?, wide? }`. Missing breakpoints
18
+ inherit from the next smaller one. So you only set the breakpoints that change.
19
+
20
+ ## Setting per-breakpoint values
21
+
22
+ Use `set_block_responsive` (or pass the object form directly in `add_block` /
23
+ `update_block` data). `read_block_type <id>` tells you which fields are
24
+ `responsive`.
25
+
26
+ ```
27
+ # 4 columns on desktop, 2 on tablet, 1 on mobile:
28
+ set_block_responsive target={kind:page,id:home} block_id=<grid-id>
29
+ field=cols value={ mobile: 1, tablet: 2, desktop: 4 }
30
+
31
+ # icon-box: icon on top on phones, beside the text on tablet+:
32
+ set_block_responsive ... block_id=<iconbox-id>
33
+ field=layout value={ mobile: "icon-top", tablet: "icon-left" }
34
+ ```
35
+
36
+ Pass a scalar to collapse a field back to one value everywhere.
37
+
38
+ ### Worked example — the classic feature grid
39
+
40
+ "4 cards/row with icon-on-top on desktop, 2/row with icon-left on a landscape
41
+ iPad, 1/row icon-on-top on a phone":
42
+
43
+ 1. `core/grid` containing `core/icon_box` cards (or a `core/repeater` with
44
+ `item_block: core/icon_box` for a collection-driven list).
45
+ 2. On the grid: `cols = { mobile: 1, tablet: 2, desktop: 4 }`.
46
+ 3. On each icon_box (or the repeater's item defaults):
47
+ `layout = { mobile: "icon-top", tablet: "icon-left", desktop: "icon-top" }`.
48
+
49
+ No media queries authored — the build emits per-instance `@media` blocks and
50
+ the editor preview honours them. Flip the device toggle in the editor header
51
+ (Mobil / Mobil-liggande / iPad / iPad-liggande / Desktop) to author and verify
52
+ each breakpoint.
53
+
54
+ ## Hiding a block at some sizes
55
+
56
+ `Block.hidden_on: Breakpoint[]` is universal — no per-block opt-in. E.g.
57
+ `hidden_on: ["mobile"]` drops the block below 640px. Use it instead of building
58
+ a "mobile-only" duplicate.
59
+
60
+ ## Authoring a CUSTOM block type that's responsive
61
+
62
+ Two halves, BOTH required (`create_block_type` / `update_block_type`):
63
+
64
+ 1. Mark the field `responsive: true`.
65
+ 2. Expose it on the **outermost** template element as a CSS variable:
66
+ `style="--{field}:{{field}}"`, then read `var(--{field})` in the block CSS.
67
+
68
+ If the field's value is directly usable CSS (e.g. `direction: row|column` →
69
+ `flex-direction: var(--direction)`), you're done.
70
+
71
+ If it's a friendly **token** that maps to CSS (e.g. `layout: icon-left` →
72
+ `flex-direction: row`), add a `responsive_css` map on the field — otherwise the
73
+ per-breakpoint overrides silently do nothing (a `[style*="--field:token"]`
74
+ selector can't see a `@media` override):
75
+
76
+ ```
77
+ { name: "layout", type: "select", options: ["icon-top","icon-left"],
78
+ default: "icon-top", responsive: true,
79
+ responsive_css: { "icon-top": "--dir: column;", "icon-left": "--dir: row;" } }
80
+ ```
81
+
82
+ Then the block CSS reads `flex-direction: var(--dir, column)`.
83
+
84
+ ## Fluid type — usually automatic
85
+
86
+ `core/heading` and prose already use `clamp()` to scale smoothly between mobile
87
+ and desktop. `core/heading` separates semantic `level` (h1–h6, for SEO) from
88
+ visual `size` (sm–3xl/auto) — "h1 but only as big as an h3" is one field, no
89
+ breakpoints needed.
90
+
91
+ ## Gotchas
92
+
93
+ - Setting a value only at `desktop` leaves smaller screens on the field
94
+ *default*, not on your value — set `mobile` too if you want a non-default
95
+ baseline (mobile-first).
96
+ - The editor preview width is approximate on a narrow panel, but the breakpoint
97
+ you're editing is exact. Trust the deployed site / a wider window for `wide`.
98
+ - **Never paper over horizontal overflow with `html,body{overflow-x:hidden}`.**
99
+ Setting `overflow-x:hidden` on `html` forces `overflow-y` to compute as `auto`
100
+ (CSS spec), turning `<html>` into a fixed-height nested scroller — the page
101
+ then won't scroll normally (`window.scrollY` sticks at 0) and renders blank
102
+ below the fold. Instead, find the element that overflows (a fixed width, a
103
+ `transform:rotate` card poking out, a decorative `::before`/`::after`, a grid
104
+ that didn't collapse) and fix THAT element's width / clip it with
105
+ `overflow:hidden` on its own section. Verify with
106
+ `document.documentElement.scrollWidth === clientWidth` at 360–390px.
107
+ - **`core/grid` `stack_at` may not collapse on mobile** (a known platform bug):
108
+ the block writes `style="--cols:N"` inline, and an inline custom property beats
109
+ the media query that tries to set `--cols:1`, so the grid stays N-up and text
110
+ wraps a letter per line. Workaround until fixed: in page-scoped CSS override the
111
+ real property, e.g. `@media(max-width:640px){.my-section [data-block="grid"]{grid-template-columns:1fr!important}}`.
112
+ - Background design reference: `docs/responsive-blocks.md` in the platform repo.