@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.
- package/AGENTS.md +43 -5
- package/README.md +10 -8
- package/dist/bundled-content.js +28 -0
- package/dist/index.js +1 -1
- package/dist/init.js +16 -0
- package/dist/server.js +6 -3
- package/dist/tools/block-types.js +32 -2
- package/dist/tools/skills.js +55 -52
- package/package.json +3 -1
- package/skills/tr-header-footer.md +174 -0
- package/skills/tr-new-site.md +18 -0
- package/skills/tr-redesign-branch.md +60 -1
- package/skills/tr-responsive.md +112 -0
|
@@ -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).
|
|
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
|
{
|
package/dist/tools/skills.js
CHANGED
|
@@ -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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
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 {
|
|
20
|
+
import { BUNDLED_SKILLS, BUNDLED_DOCS } from '../bundled-content.js';
|
|
18
21
|
import { ok, withErrorBoundary } from './helpers.js';
|
|
19
|
-
/** The only
|
|
20
|
-
*
|
|
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
|
|
29
|
-
*
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
/**
|
|
60
|
-
* `tr-<kebab>`
|
|
61
|
-
|
|
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
|
|
67
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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.
|
|
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 `/`.
|
package/skills/tr-new-site.md
CHANGED
|
@@ -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
|
-
|
|
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.
|