@typeroll/mcp-server 0.7.5 → 0.7.7

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/dist/index.js CHANGED
@@ -17,6 +17,7 @@
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
19
  import { TyperollClient } from './client.js';
20
+ import { runInstallSkillsCli } from './install-skills.js';
20
21
  import { pageTools } from './tools/pages.js';
21
22
  import { partialTools } from './tools/partials.js';
22
23
  import { collectionTools } from './tools/collections.js';
@@ -32,7 +33,7 @@ import { blockTypeTools } from './tools/block-types.js';
32
33
  import { pageBlockTools } from './tools/page-blocks.js';
33
34
  import { settingsTools } from './tools/settings.js';
34
35
  import { siteTools } from './tools/sites.js';
35
- const VERSION = '0.1.0';
36
+ const VERSION = '0.7.7';
36
37
  async function resolveSiteId(client) {
37
38
  const fromEnv = process.env.TYPEROLL_SITE_ID?.trim();
38
39
  if (fromEnv)
@@ -53,6 +54,20 @@ function bail(message) {
53
54
  process.exit(1);
54
55
  }
55
56
  async function main() {
57
+ // Subcommand dispatch. These run without any of the MCP-server env-var
58
+ // validation below, since they don't talk to the portal.
59
+ const argv = process.argv.slice(2);
60
+ if (argv[0] === 'install-skills') {
61
+ const code = await runInstallSkillsCli(argv.slice(1));
62
+ process.exit(code);
63
+ }
64
+ if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
65
+ console.error('Usage:');
66
+ console.error(' typeroll-mcp Start the MCP server (reads TYPEROLL_API_URL and TYPEROLL_API_KEY)');
67
+ console.error(' typeroll-mcp install-skills <dir> [-f] Copy bundled skill files to <dir>');
68
+ console.error(' typeroll-mcp --help Show this help');
69
+ process.exit(0);
70
+ }
56
71
  const apiUrl = process.env.TYPEROLL_API_URL?.trim();
57
72
  const apiKey = process.env.TYPEROLL_API_KEY?.trim();
58
73
  if (!apiUrl)
@@ -0,0 +1,130 @@
1
+ // install-skills subcommand for the typeroll-mcp CLI.
2
+ //
3
+ // Copies the bundled skill markdown files from this package's `skills/`
4
+ // directory into a destination directory the user provides. Used to seed
5
+ // `.claude/skills/` (project scope) or `~/.claude/skills/` (user scope) so
6
+ // Claude Code picks up Typeroll-specific skill recipes.
7
+ //
8
+ // This subcommand does NOT need TYPEROLL_API_URL / TYPEROLL_API_KEY — it's a
9
+ // pure file copy. Putting the dispatch in index.ts before the env-var
10
+ // validation is the only thing that matters for that.
11
+ import { promises as fs } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ // Resolve the bundled skills directory. At runtime this file lives at
15
+ // <package>/dist/install-skills.js (after tsc); skills/ is a sibling of dist/.
16
+ // fileURLToPath gives us a real OS path that works on every platform.
17
+ function skillsDir() {
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ return path.resolve(here, '..', 'skills');
20
+ }
21
+ /**
22
+ * Copy all `tr-*.md` files from the bundled skills directory to `dest`.
23
+ * Returns a summary so callers can render output. Throws on filesystem errors
24
+ * the caller didn't ask for.
25
+ */
26
+ export async function installSkills(opts) {
27
+ const source = opts.sourceDir ?? skillsDir();
28
+ const force = opts.force ?? false;
29
+ // Validate the source dir exists. If this fails the package install is
30
+ // broken (skills/ missing from the tarball) — surface clearly.
31
+ try {
32
+ const stat = await fs.stat(source);
33
+ if (!stat.isDirectory()) {
34
+ throw new Error(`bundled skills path is not a directory: ${source}`);
35
+ }
36
+ }
37
+ catch (e) {
38
+ const reason = e instanceof Error ? e.message : String(e);
39
+ throw new Error(`could not find bundled skills directory at ${source}: ${reason}`);
40
+ }
41
+ // Resolve destination. Create it if missing (mkdir -p semantics).
42
+ const dest = path.resolve(opts.dest);
43
+ await fs.mkdir(dest, { recursive: true });
44
+ // Only copy tr-*.md — README.md in skills/ is package-internal, not a skill.
45
+ const entries = await fs.readdir(source);
46
+ const skillFiles = entries.filter((f) => f.startsWith('tr-') && f.endsWith('.md')).sort();
47
+ const copied = [];
48
+ const skipped = [];
49
+ for (const file of skillFiles) {
50
+ const target = path.join(dest, file);
51
+ if (!force) {
52
+ try {
53
+ await fs.access(target);
54
+ skipped.push(file);
55
+ continue;
56
+ }
57
+ catch {
58
+ // File doesn't exist — proceed to copy.
59
+ }
60
+ }
61
+ await fs.copyFile(path.join(source, file), target);
62
+ copied.push(file);
63
+ }
64
+ return { destination: dest, copied, skipped };
65
+ }
66
+ /**
67
+ * CLI entry point — parses argv (after the subcommand token has been
68
+ * removed) and prints human-readable output. Returns exit code.
69
+ */
70
+ export async function runInstallSkillsCli(args) {
71
+ let dest;
72
+ let force = false;
73
+ for (const arg of args) {
74
+ if (arg === '--force' || arg === '-f') {
75
+ force = true;
76
+ }
77
+ else if (arg === '--help' || arg === '-h') {
78
+ printHelp();
79
+ return 0;
80
+ }
81
+ else if (!dest && !arg.startsWith('-')) {
82
+ dest = arg;
83
+ }
84
+ else {
85
+ console.error(`typeroll-mcp install-skills: unrecognised argument: ${arg}`);
86
+ printHelp();
87
+ return 1;
88
+ }
89
+ }
90
+ if (!dest) {
91
+ console.error('typeroll-mcp install-skills: missing destination directory.');
92
+ printHelp();
93
+ return 1;
94
+ }
95
+ try {
96
+ const result = await installSkills({ dest, force });
97
+ const total = result.copied.length + result.skipped.length;
98
+ console.log(`typeroll-mcp: installed skills to ${result.destination}`);
99
+ console.log(` copied: ${result.copied.length}/${total}`);
100
+ if (result.skipped.length > 0) {
101
+ console.log(` skipped: ${result.skipped.length} (already exist; rerun with --force to overwrite)`);
102
+ for (const f of result.skipped)
103
+ console.log(` - ${f}`);
104
+ }
105
+ if (result.copied.length > 0) {
106
+ for (const f of result.copied)
107
+ console.log(` + ${f}`);
108
+ }
109
+ return 0;
110
+ }
111
+ catch (e) {
112
+ const reason = e instanceof Error ? e.message : String(e);
113
+ console.error(`typeroll-mcp install-skills: ${reason}`);
114
+ return 1;
115
+ }
116
+ }
117
+ function printHelp() {
118
+ console.error('');
119
+ console.error('Usage: npx @typeroll/mcp-server install-skills <destination> [--force]');
120
+ console.error('');
121
+ console.error('Copies bundled Typeroll skill markdown files to the destination directory.');
122
+ console.error('');
123
+ console.error('Common destinations:');
124
+ console.error(' .claude/skills Project-scoped (recommended)');
125
+ console.error(' ~/.claude/skills User-scoped (available in every project)');
126
+ console.error('');
127
+ console.error('Options:');
128
+ console.error(' --force, -f Overwrite files that already exist');
129
+ console.error(' --help, -h Show this help');
130
+ }
@@ -40,14 +40,23 @@ export const pageTools = [
40
40
  },
41
41
  {
42
42
  name: 'batch_read_pages',
43
- description: 'Read up to 200 pages in a single call. Use to bulk-load context before a redesign sweep.',
43
+ description: 'Read up to 200 pages in a single call. Returns pages_by_id — a map keyed by page_id for easy lookup — plus a not_found list. Use to bulk-load context before a redesign sweep.',
44
44
  inputSchema: {
45
45
  page_ids: z.array(z.string()).min(1).max(200),
46
46
  version: versionParam,
47
47
  },
48
48
  handler: withErrorBoundary(async (args, { client, siteId }) => {
49
49
  const res = await client.post(siteId, 'pages/batch-read', { page_ids: args.page_ids }, v(args.version));
50
- return ok(res);
50
+ // Transform array → map keyed by page_id for ergonomic access.
51
+ const pages_by_id = {};
52
+ const not_found = [];
53
+ for (const entry of res.pages ?? []) {
54
+ if (entry.found && entry.page)
55
+ pages_by_id[entry.page_id] = entry.page;
56
+ else
57
+ not_found.push(entry.page_id);
58
+ }
59
+ return ok({ pages_by_id, not_found });
51
60
  }),
52
61
  },
53
62
  {
@@ -54,14 +54,19 @@ export const partialTools = [
54
54
  },
55
55
  {
56
56
  name: 'replace_partial',
57
- description: 'Full replace of a global block (PUT). Use this when you want to set every field including auto-inferring kind from id.',
57
+ description: 'Full replace of a global block (PUT). Pass html_content (and optionally name/status) directly no wrapper object needed. ' +
58
+ 'kind is auto-inferred from partial_id ("header" → header, "footer" → footer, anything else → free). ' +
59
+ 'For incremental edits (changing one field without replacing the whole block) use update_partial instead.',
58
60
  inputSchema: {
59
- partial_id: z.string(),
60
- partial: z.object({ html_content: z.string() }).passthrough(),
61
+ partial_id: z.string().describe('"header", "footer", or a free-block kebab-id.'),
62
+ html_content: z.string().describe('Full HTML for the block. Replaces any existing content.'),
63
+ name: z.string().optional().describe('Human-readable label shown in the UI.'),
64
+ status: z.enum(['draft', 'published']).optional(),
61
65
  version: versionParam,
62
66
  },
63
67
  handler: withErrorBoundary(async (args, { client, siteId }) => {
64
- const res = await client.put(siteId, `partials/${encodeURIComponent(args.partial_id)}`, args.partial, v(args.version));
68
+ const { version, partial_id, ...fields } = args;
69
+ const res = await client.put(siteId, `partials/${encodeURIComponent(partial_id)}`, fields, v(version));
65
70
  return ok(res);
66
71
  }),
67
72
  },
@@ -6,10 +6,13 @@ function v(version) {
6
6
  export const searchTools = [
7
7
  {
8
8
  name: 'search_pages',
9
- description: 'Search page bodies by literal substring or regex. Returns up to 500 matches each with an excerpt around the first hit. Use this to scope a redesign or a bulk replacement before running it.',
9
+ description: 'Search page bodies by literal substring or regex. Returns up to 500 matches each with an excerpt around the first hit. Use this to scope a redesign or a bulk replacement before running it. ' +
10
+ 'Pass either "contains" (case-insensitive literal) or "regex" (JS regex source without slashes) — not both. ' +
11
+ 'Example: search_pages({"contains": "kontakta oss"}) finds every page mentioning that phrase. ' +
12
+ 'Example: search_pages({"regex": "\\\\d{3}-\\\\d{3}"}) finds pages with phone-number patterns.',
10
13
  inputSchema: {
11
- contains: z.string().optional().describe('Case-insensitive literal substring'),
12
- regex: z.string().optional().describe('JS regex source (without slashes), case-insensitive'),
14
+ contains: z.string().optional().describe('Case-insensitive literal substring. Example: "kontakta oss"'),
15
+ regex: z.string().optional().describe('JS regex source without surrounding slashes, case-insensitive. Example: "\\\\d{3}-\\\\d{3}" matches phone patterns.'),
13
16
  limit: z.number().int().min(1).max(500).optional(),
14
17
  version: versionParam,
15
18
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills/README.md CHANGED
@@ -27,13 +27,31 @@ ln -s "$PWD/skills/tr-migrate-wp.md" ~/.claude/skills/
27
27
 
28
28
  ## The skills
29
29
 
30
+ ### Bygga och designa
31
+
32
+ | File | When it triggers | What it does |
33
+ |---|---|---|
34
+ | `tr-new-site.md` | "create a new site", "bootstrap a site for…" | Settings → header/footer partials → homepage → inner pages → deploy. Full setup recipe. |
35
+ | `tr-brand.md` | "create a brand", "choose colors", "design the look"| Palette recipes by mood, typography pairings, CSS variable setup, preview. |
36
+ | `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved. |
37
+ | `tr-content-write.md` | "write a page about…", "draft copy for…" | Discovery first (settings + sample pages), then drafts in the site's voice, previews, iterates. |
38
+ | `tr-images.md` | "make an image / hero / illustration", media uploads | Generates locally → signed upload URL → metadata patch → embed. |
39
+
40
+ ### Funktioner
41
+
42
+ | File | When it triggers | What it does |
43
+ |---|---|---|
44
+ | `tr-blog.md` | "add a blog", "set up news", "article section" | Collection schema → seed items → listing page → per-article pages → nav update → deploy. |
45
+ | `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
46
+ | `tr-directory.md` | Building a directory site, importing structured data | Schema → items → per-item URLs via `route_template` → listing page → preview → deploy. |
47
+ | `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
48
+
49
+ ### Importera innehåll
50
+
30
51
  | File | When it triggers | What it does |
31
52
  |---|---|---|
32
- | `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review |
33
- | `tr-content-write.md` | "write a page about…", "draft copy for…" | Discovery first (settings + sample pages), then drafts in the site's voice, previews, iterates |
34
- | `tr-images.md` | "make an image / hero / illustration", media uploads | Generates locally → signed upload URL → metadata patch → embed |
35
- | `tr-directory.md` | Building a directory site, importing structured data | Schema → items → per-item URLs via `route_template` → listing page → preview → deploy |
36
- | `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved |
53
+ | `tr-migrate-wp.md` | "migrate from WordPress", a wp-json URL is mentioned | Walks the WP REST, rebuilds each page in the target's design, transfers media, sets redirects, leaves everything as drafts for review. |
54
+ | `tr-import-url.md` | "import from Squarespace/Wix/Webflow", any non-WP URL| Fetch clean adapt to target design media transfer draft pages → redirects → deploy. |
37
55
 
38
56
  ## Prerequisites for every skill
39
57
 
@@ -0,0 +1,177 @@
1
+ ---
2
+ name: tr-blog
3
+ description: Use when the user wants to set up a blog, news section, or article feed on a Typeroll site. Triggers on "add a blog", "set up news", "article section", "create posts", "inlägg", "nyheter", or when the user wants to manage a list of dated articles.
4
+ ---
5
+
6
+ # Set up a blog / news section
7
+
8
+ Typeroll doesn't have a built-in blog — instead the AI writes listing HTML
9
+ directly into a page, sourced from a collection. This skill wires the whole
10
+ flow: collection schema → seed items → listing page → individual item URLs
11
+ via `route_template` → deploy.
12
+
13
+ ## Preconditions
14
+
15
+ - Site exists with working header/footer.
16
+ - You know the desired collection name (e.g. `blog`, `news`, `artiklar`).
17
+
18
+ ## Recipe
19
+
20
+ ### 1. Create the collection
21
+
22
+ ```
23
+ create_collection {
24
+ "name": "blog",
25
+ "label_singular": "Artikel",
26
+ "label_plural": "Artiklar",
27
+ "slug_field": "slug",
28
+ "sort_field": "date",
29
+ "sort_dir": "desc",
30
+ "route_template": "/blog/{slug}",
31
+ "fields": [
32
+ {"name": "title", "type": "text", "label": "Rubrik", "required": true},
33
+ {"name": "slug", "type": "text", "label": "URL-slug", "required": true},
34
+ {"name": "date", "type": "date", "label": "Datum", "required": true},
35
+ {"name": "author", "type": "text", "label": "Författare"},
36
+ {"name": "excerpt", "type": "textarea", "label": "Ingress"},
37
+ {"name": "body", "type": "richtext", "label": "Brödtext"},
38
+ {"name": "image", "type": "image", "label": "Omslagsbild"},
39
+ {"name": "tags", "type": "text", "label": "Taggar (kommaseparerade)"}
40
+ ]
41
+ }
42
+ ```
43
+
44
+ **Field name rule:** ASCII only, lowercase. `ä→a`, `ö→o`, `å→a`.
45
+
46
+ ### 2. Seed with real content
47
+
48
+ Add 2–3 initial articles. Use `create_collection_item`:
49
+
50
+ ```
51
+ create_collection_item collection="blog" fields={
52
+ "title": "Vår designfilosofi",
53
+ "slug": "var-designfilosofi",
54
+ "date": "2025-05-15",
55
+ "author": "Anna Lindström",
56
+ "excerpt": "Vi tror på enkelhet med syfte — varje beslut ska kunna motiveras.",
57
+ "body": "<p>...</p>",
58
+ "image": "https://cdn.typeroll.com/..."
59
+ }
60
+ ```
61
+
62
+ If `image` is a URL, upload it first via `upload_media_from_url` and use
63
+ the returned CDN URL.
64
+
65
+ ### 3. List all items for the listing page
66
+
67
+ ```
68
+ list_collection_items collection="blog"
69
+ ```
70
+
71
+ Use the response to generate the listing HTML. The static site has no
72
+ template engine — the HTML is the listing.
73
+
74
+ ### 4. Create the listing page
75
+
76
+ Build HTML manually from the collection items. Include all the data you
77
+ want visible without a detail click. Example structure:
78
+
79
+ ```html
80
+ <section class="blog-listing">
81
+ <div class="container">
82
+ <h1 class="section-title">Artiklar</h1>
83
+ <div class="blog-grid">
84
+ <!-- one .blog-card per item -->
85
+ <article class="blog-card">
86
+ <a href="/blog/var-designfilosofi">
87
+ <img src="https://cdn.typeroll.com/..." alt="Omslagsbild">
88
+ <div class="blog-card__body">
89
+ <time class="blog-card__date">15 maj 2025</time>
90
+ <h2 class="blog-card__title">Vår designfilosofi</h2>
91
+ <p class="blog-card__excerpt">Vi tror på enkelhet med syfte...</p>
92
+ <span class="blog-card__cta">Läs mer →</span>
93
+ </div>
94
+ </a>
95
+ </article>
96
+ </div>
97
+ </div>
98
+ </section>
99
+ <style>
100
+ .blog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:2rem}
101
+ .blog-card{border:1px solid var(--color-surface);border-radius:0.5rem;overflow:hidden}
102
+ .blog-card a{text-decoration:none;display:block;color:var(--color-text)}
103
+ .blog-card img{width:100%;aspect-ratio:16/9;object-fit:cover}
104
+ .blog-card__body{padding:1.5rem}
105
+ .blog-card__date{font-size:0.8rem;color:var(--color-text-light);display:block;margin-bottom:0.5rem}
106
+ .blog-card__title{font-family:var(--font-heading);font-size:1.25rem;margin-bottom:0.5rem}
107
+ .blog-card__excerpt{color:var(--color-text-light);font-size:0.9rem;margin-bottom:1rem}
108
+ .blog-card__cta{color:var(--color-accent);font-size:0.85rem;font-weight:600}
109
+ </style>
110
+ ```
111
+
112
+ ```
113
+ create_page title="Artiklar" slug="blog"
114
+ html_content="<listing HTML>"
115
+ content_mode="html" status="published"
116
+ ```
117
+
118
+ ### 5. Create individual article pages
119
+
120
+ Each collection item with `route_template: "/blog/{slug}"` gets its own
121
+ URL at deploy time IF you create a matching page for each article. Create
122
+ one page per article:
123
+
124
+ ```
125
+ create_page title="Vår designfilosofi" slug="blog/var-designfilosofi"
126
+ html_content="<full article HTML>"
127
+ content_mode="html" kind="article"
128
+ author="Anna Lindström"
129
+ seo_title="Vår designfilosofi — Acme Studio"
130
+ seo_description="Ingress here"
131
+ status="published"
132
+ ```
133
+
134
+ The slug `blog/var-designfilosofi` maps to URL `/blog/var-designfilosofi`.
135
+
136
+ **Important:** Typeroll does NOT auto-generate detail pages from collection
137
+ items. You write the detail HTML per article — this gives full control over
138
+ the design but means each article is a separate `create_page` call.
139
+
140
+ ### 6. Update nav to link to the blog
141
+
142
+ Read the header partial and add a "Blog" or "Artiklar" link:
143
+ ```
144
+ read_partial partial_id="header"
145
+ replace_partial partial_id="header" html_content="<updated with blog link>"
146
+ ```
147
+
148
+ ### 7. Keep listings in sync
149
+
150
+ When new articles are added later, the AI must:
151
+ 1. `create_collection_item` with the article data
152
+ 2. `create_page` for the article's detail URL
153
+ 3. Re-read the full collection via `list_collection_items`
154
+ 4. Regenerate the listing HTML and `update_page` the listing page
155
+
156
+ Or call `regenerate_collection_listing collection="blog"` if the site has
157
+ that route configured — it reruns the listing generation.
158
+
159
+ ### 8. Deploy
160
+
161
+ ```
162
+ trigger_deploy
163
+ get_deploy_status job_id=<id>
164
+ ```
165
+
166
+ ## Pitfalls
167
+
168
+ - **Listing page goes stale.** Every new article needs the listing page
169
+ regenerated. There's no auto-sync; you must update the HTML.
170
+ - **Don't use non-ASCII field names.** `datum` not `datum`, `rubrik` not
171
+ `Rubrik` as the field name. The `label` can be anything; the `name` must
172
+ be `[a-z][a-z0-9_-]*`.
173
+ - **Article slugs must be globally unique.** `/blog/` prefix in the slug
174
+ avoids collisions with non-blog pages.
175
+ - **`route_template` is decorative without matching pages.** The template
176
+ declares the URL structure; actual routing depends on pages existing at
177
+ those paths. Always create the page.
@@ -0,0 +1,169 @@
1
+ ---
2
+ name: tr-brand
3
+ description: Use when the user asks to create a brand identity, design system, or visual style for a site. Triggers on "create a brand", "design the look", "choose colors", "pick fonts", "make it look like [reference]", or "rebrand the site". Produces a cohesive palette, typography scale, and CSS custom properties applied to an existing site.
4
+ ---
5
+
6
+ # Design a brand identity for a Typeroll site
7
+
8
+ This skill turns a brief (or a reference URL/screenshot) into a complete
9
+ visual design system applied to the site's settings and partials.
10
+
11
+ ## Preconditions
12
+
13
+ - Site exists and MCP is configured.
14
+ - You have at least one of: industry, mood words, reference URL, existing
15
+ logo colors, or competitor sites to contrast with.
16
+
17
+ ## Step 1 — Gather context
18
+
19
+ Ask (or infer from the brief):
20
+
21
+ 1. **Industry + audience.** Law firm → formal, trust. Café → warm, approachable.
22
+ Tech startup → clean, modern. Interior design → refined, editorial.
23
+ 2. **Mood words.** 3–5 adjectives the brand should feel: "minimal, Nordic,
24
+ calm" or "bold, energetic, playful".
25
+ 3. **Reference.** A URL, a screenshot, or a competitor they like (and what
26
+ they want to be different from it).
27
+ 4. **Must-keep.** Existing logo color? Legal industry color conventions?
28
+
29
+ If the user provided a URL, fetch it and note the dominant colors,
30
+ typeface categories, and layout density.
31
+
32
+ ## Step 2 — Build the palette
33
+
34
+ A Typeroll site uses 7 color tokens:
35
+
36
+ | Token | Role | Design rule |
37
+ |---|---|---|
38
+ | `primary` | Brand identity. CTA buttons, active nav, links. | High contrast on `background`. |
39
+ | `secondary` | Header, footer, darker sections. | Darker or more neutral than primary. |
40
+ | `accent` | Highlights, price tags, badges, hover states. | High-energy complement. |
41
+ | `background` | Page background. | Near-white for light themes, near-black for dark. |
42
+ | `surface` | Cards, input boxes, code blocks. | Slightly off from `background`. |
43
+ | `text` | Body copy. | ≥4.5:1 contrast ratio on `background`. |
44
+ | `text_light` | Secondary labels, captions, placeholders. | ≥3:1 on `background`. |
45
+
46
+ **Palette recipes by mood:**
47
+
48
+ *Nordic / minimal:*
49
+ ```
50
+ primary: #1f2a30 secondary: #142027 accent: #c9b89a
51
+ background: #faf8f4 surface: #f2ede5 text: #1f2a30 text_light: #7a7265
52
+ ```
53
+
54
+ *Warm / artisan:*
55
+ ```
56
+ primary: #3d2b1f secondary: #2a1d14 accent: #c8860a
57
+ background: #fdf6ee surface: #f7ede0 text: #1a1008 text_light: #8a7060
58
+ ```
59
+
60
+ *Modern / tech:*
61
+ ```
62
+ primary: #2563eb secondary: #1e293b accent: #f59e0b
63
+ background: #ffffff surface: #f8fafc text: #0f172a text_light: #64748b
64
+ ```
65
+
66
+ *Editorial / dark:*
67
+ ```
68
+ primary: #e2c08d secondary: #0f0f0f accent: #e2c08d
69
+ background: #0f0f0f surface: #1a1a1a text: #f5f5f0 text_light: #a0a090
70
+ ```
71
+
72
+ Check WCAG contrast ratios mentally: text on background must be ≥4.5:1.
73
+ The online tool `https://webaim.org/resources/contrastchecker/` is useful
74
+ but not accessible during a tool call — reason about perceived contrast
75
+ instead (light grey on white = bad; dark grey on white = fine).
76
+
77
+ ## Step 3 — Choose typefaces
78
+
79
+ Pick from high-quality Google Fonts pairings:
80
+
81
+ | Heading | Body | Mood |
82
+ |---|---|---|
83
+ | Cormorant Garamond | Raleway | Luxury, editorial |
84
+ | Playfair Display | Source Sans 3 | Classic, readable |
85
+ | DM Serif Display | DM Sans | Contemporary, clean |
86
+ | Fraunces | Mulish | Artisan, craft |
87
+ | Syne | Inter | Bold, modern |
88
+ | Plus Jakarta Sans | Plus Jakarta Sans | Clean, versatile |
89
+ | Libre Baskerville | Libre Franklin | Traditional, trustworthy |
90
+
91
+ Same font for heading and body is fine if it has enough weight variation
92
+ (Inter at 700 + 400 works well).
93
+
94
+ `size_base` should be 16 for most sites; 17–18 for text-heavy editorial
95
+ sites; 15 for dense dashboards.
96
+
97
+ ## Step 4 — Apply to the site
98
+
99
+ One call sets everything:
100
+
101
+ ```
102
+ update_site_settings {
103
+ "colors": { ...all 7 tokens },
104
+ "fonts": { "heading": "...", "body": "...", "size_base": 16 },
105
+ "custom_css": "/* optional: utility classes or @keyframes */"
106
+ }
107
+ ```
108
+
109
+ Read back to confirm: `read_site_settings`.
110
+
111
+ ## Step 5 — Update partials to use the new palette
112
+
113
+ Partials that hardcoded hex colors need updating. Fetch the header:
114
+
115
+ ```
116
+ read_partial partial_id="header"
117
+ ```
118
+
119
+ If it has hardcoded colors, replace them with CSS variable references
120
+ (`var(--color-primary)`) and call `replace_partial`:
121
+
122
+ ```
123
+ replace_partial partial_id="header" html_content="<updated HTML>"
124
+ ```
125
+
126
+ Same for footer.
127
+
128
+ ## Step 6 — Custom CSS for advanced tokens (optional)
129
+
130
+ If the brand needs things beyond the 7 base tokens — e.g. a gradient,
131
+ a special border radius, or a branded highlight color — add them via
132
+ `custom_css`:
133
+
134
+ ```css
135
+ :root {
136
+ --brand-gradient: linear-gradient(135deg, var(--color-primary), var(--color-accent));
137
+ --radius-brand: 2px; /* sharp corners for formal brands */
138
+ --letter-spacing-display: -0.03em; /* tight tracking for display headings */
139
+ }
140
+ ```
141
+
142
+ Then reference `var(--brand-gradient)` etc. in page HTML and partials.
143
+
144
+ ## Step 7 — Preview
145
+
146
+ ```
147
+ get_preview_link
148
+ ```
149
+
150
+ Open in browser. Check:
151
+ - Colors render as intended (not "undefined" or missing)
152
+ - Fonts load (Google Fonts link is in `<head>`)
153
+ - Nav text is readable against header background
154
+ - Body text has sufficient contrast
155
+
156
+ ## Pitfalls
157
+
158
+ - **Don't set colors without checking the header contrast.** If `primary`
159
+ is light, white nav text becomes unreadable. Either darken `primary` or
160
+ make the header use `secondary`.
161
+ - **Custom_css is global.** Rules here apply to every page. Keep it to
162
+ `:root {}` token additions and truly global utilities. Page-specific
163
+ styles go in the page's HTML `<style>` block.
164
+ - **Google Fonts load time.** Two different font families is fine; three
165
+ adds measurable LCP impact. Stick to two families with variable-font
166
+ versions when possible.
167
+ - **Dark themes need dark surface too.** Setting `background: #0f0f0f`
168
+ but leaving `surface: #f8fafc` (white) breaks every card/input. Always
169
+ update all 7 tokens as a set.