saglitzdesign-mcp 0.4.0 → 0.5.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/README.md CHANGED
@@ -9,7 +9,7 @@ expert‑level guidance on **web, iOS, Android and macOS design** — plus the
9
9
  **UX, copywriting, SEO, GEO and marketing** knowledge that makes a product
10
10
  actually convert.
11
11
 
12
- 56 curated knowledge documents · 10 tools · 5 build/review workflows · phased roadmaps · real‑world visual examples
12
+ 57 curated knowledge documents · 12 tools · 7 build/review workflows · real token & a11y generators · phased roadmaps · real‑world visual examples
13
13
 
14
14
  [![npm](https://img.shields.io/npm/v/saglitzdesign-mcp?color=cb3837&logo=npm)](https://www.npmjs.com/package/saglitzdesign-mcp)
15
15
  [![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
@@ -71,6 +71,8 @@ scores it against the critique rubric, and iterates until it passes.
71
71
  | **`build_landing_page`** | Designs & builds a conversion‑focused landing page, copy‑first, with a visual critique loop. |
72
72
  | **`build_website`** | Builds a multi‑page marketing site — positioning, IA, SEO/GEO, shared design system. |
73
73
  | **`build_mobile_app_ui`** | Builds iOS or Android screens on the correct platform baseline (HIG/Liquid Glass or Material 3). |
74
+ | **`critique_screenshot`** | Grounded, reproducible critique of an attached UI screenshot against the fixed 0–40 rubric — cites specific elements, no padding. |
75
+ | **`review_paywall`** | Scores a paywall / subscription onboarding against real RevenueCat 2026 conversion benchmarks. |
74
76
  | **`design_review`** | Audits an existing site/app against the checklists and the 0–40 critique rubric, ranked by severity. |
75
77
  | **`redesign`** | Improves an existing UI (bolder / quieter / higher‑converting) using the craft standards, with before→after scoring. |
76
78
 
@@ -92,6 +94,8 @@ scores it against the critique rubric, and iterates until it passes.
92
94
  | **`get_design_examples`** | **Real screenshots** of a pattern from top apps/sites, returned as images with notes on what each does well. |
93
95
  | **`design_review_checklist`** | An assembled audit checklist per project type and focus (UI, UX, accessibility, SEO, GEO, conversion, copywriting). |
94
96
  | **`seo_geo_guide`** | SEO and GEO guides, optionally narrowed to a topic. |
97
+ | **`generate_design_tokens`** | **Real artifacts, not advice** — turns a color/spacing/type spec into CSS variables, Tailwind v4, SwiftUI, Jetpack Compose, and W3C DTCG JSON. |
98
+ | **`audit_accessibility`** | Deterministic WCAG 2.2 checks — exact contrast ratios for color pairs + tap‑target sizes per platform, with fixes. |
95
99
  | **`list_design_knowledge`** | Browse the full knowledge index by category / platform. |
96
100
  | **`knowledge_freshness`** | Reports each doc's age vs a per‑category staleness threshold, so the base can be kept current. |
97
101
 
package/dist/a11y.js ADDED
@@ -0,0 +1,98 @@
1
+ // Deterministic, design-time accessibility checks: WCAG 2.2 contrast + tap targets.
2
+ // Mirrors the axe-style rules designers wish ran at design time, not just in CI.
3
+ import { normalizeHex } from "./tokens.js";
4
+ function channelLuminance(c) {
5
+ const s = c / 255;
6
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
7
+ }
8
+ function relativeLuminance(hex) {
9
+ const h = normalizeHex(hex).slice(1);
10
+ const r = parseInt(h.slice(0, 2), 16);
11
+ const g = parseInt(h.slice(2, 4), 16);
12
+ const b = parseInt(h.slice(4, 6), 16);
13
+ return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
14
+ }
15
+ /** WCAG contrast ratio between two opaque colors (1..21). */
16
+ export function contrastRatio(fg, bg) {
17
+ const l1 = relativeLuminance(fg);
18
+ const l2 = relativeLuminance(bg);
19
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
20
+ return (hi + 0.05) / (lo + 0.05);
21
+ }
22
+ export function checkContrast(pairs) {
23
+ return pairs.map((p, i) => {
24
+ const label = p.label ?? `pair ${i + 1}`;
25
+ const fg = normalizeHex(p.foreground);
26
+ const bg = normalizeHex(p.background);
27
+ if (!fg || !bg) {
28
+ return { label, detail: `${p.foreground} on ${p.background}`, required: "valid hex", actual: "invalid hex", pass: false, fix: "Use #RGB / #RRGGBB / #RRGGBBAA." };
29
+ }
30
+ const ratio = contrastRatio(fg, bg);
31
+ const required = p.ui_component ? 3 : p.large_text ? 3 : 4.5;
32
+ const kind = p.ui_component ? "non-text UI (WCAG 1.4.11)" : p.large_text ? "large text (WCAG 1.4.3 AA)" : "normal text (WCAG 1.4.3 AA)";
33
+ const pass = ratio >= required;
34
+ return {
35
+ label,
36
+ detail: `${fg} on ${bg} — ${kind}`,
37
+ required: `≥ ${required}:1`,
38
+ actual: `${ratio.toFixed(2)}:1`,
39
+ pass,
40
+ fix: pass ? undefined : `Increase contrast to ≥${required}:1 (darken/lighten one color). AAA target is ${p.large_text || p.ui_component ? "4.5" : "7"}:1.`,
41
+ };
42
+ });
43
+ }
44
+ export function checkTargets(targets) {
45
+ const MIN = { ios: 44, android: 48, web: 24 }; // pt / dp / css px (WCAG 2.2 min for web)
46
+ const REC = 44; // recommended comfortable minimum across platforms
47
+ return targets.map((t, i) => {
48
+ const platform = t.platform ?? "web";
49
+ const min = MIN[platform];
50
+ const smaller = Math.min(t.width, t.height);
51
+ const pass = smaller >= min;
52
+ const unit = platform === "ios" ? "pt" : platform === "android" ? "dp" : "px";
53
+ return {
54
+ label: t.label ?? `target ${i + 1}`,
55
+ detail: `${t.width}×${t.height}${unit} (${platform})`,
56
+ required: `≥ ${min}×${min}${unit}${platform === "web" ? ` (rec. ${REC})` : ""}`,
57
+ actual: `${smaller}${unit} smallest side`,
58
+ pass,
59
+ fix: pass ? undefined : `Enlarge to ≥${min}${unit}, or expand the hit area (padding) beyond the visible bounds.`,
60
+ };
61
+ });
62
+ }
63
+ export function contrastReport(pairs, targets) {
64
+ const out = ["# Accessibility audit (WCAG 2.2)"];
65
+ const cr = checkContrast(pairs);
66
+ const tr = checkTargets(targets);
67
+ const all = [...cr, ...tr];
68
+ const fails = all.filter((r) => !r.pass).length;
69
+ out.push(`\n**${all.length} checks · ${all.length - fails} pass · ${fails} fail**\n`);
70
+ if (cr.length) {
71
+ out.push("## Color contrast");
72
+ out.push("| element | measured | required | result |");
73
+ out.push("|---|---|---|---|");
74
+ for (const r of cr)
75
+ out.push(`| ${r.label} — ${r.detail} | ${r.actual} | ${r.required} | ${r.pass ? "✅ pass" : "❌ FAIL"} |`);
76
+ const failFixes = cr.filter((r) => !r.pass);
77
+ if (failFixes.length) {
78
+ out.push("\n**Fixes:**");
79
+ for (const r of failFixes)
80
+ out.push(`- **${r.label}:** ${r.fix}`);
81
+ }
82
+ }
83
+ if (tr.length) {
84
+ out.push("\n## Tap / target sizes");
85
+ out.push("| target | size | required | result |");
86
+ out.push("|---|---|---|---|");
87
+ for (const r of tr)
88
+ out.push(`| ${r.label} | ${r.detail} | ${r.required} | ${r.pass ? "✅ pass" : "❌ FAIL"} |`);
89
+ const failFixes = tr.filter((r) => !r.pass);
90
+ if (failFixes.length) {
91
+ out.push("\n**Fixes:**");
92
+ for (const r of failFixes)
93
+ out.push(`- **${r.label}:** ${r.fix}`);
94
+ }
95
+ }
96
+ out.push("\n---\n_Deterministic checks only (contrast + target size). These are ~the machine-verifiable slice of accessibility; axe-core-style automation catches ~57% of issues by volume. Still verify manually: keyboard/focus order, screen-reader labels & announcements, Dynamic Type at 200%, motion, and cognitive load. See get_design_doc(\"accessibility\")._");
97
+ return out.join("\n");
98
+ }
package/dist/index.js CHANGED
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from "node:fs";
8
8
  import { loadKnowledge, searchKnowledge, sections } from "./knowledge.js";
9
9
  import { loadExamples, searchExamples, imageMime } from "./examples.js";
10
10
  import { registerPrompts } from "./prompts.js";
11
+ import { generateTokens, validateColors, DEFAULT_SPACING, DEFAULT_RADII, DEFAULT_FONT_SIZES, DEFAULT_FONT_FAMILIES, } from "./tokens.js";
12
+ import { contrastReport } from "./a11y.js";
11
13
  // knowledge/ sits next to dist/ (repo root) both in dev (tsx) and after build
12
14
  const here = dirname(fileURLToPath(import.meta.url));
13
15
  const knowledgeDir = [join(here, "..", "knowledge"), join(here, "..", "..", "knowledge")].find(existsSync);
@@ -20,7 +22,7 @@ const examplesDir = join(knowledgeDir, "examples");
20
22
  const examples = loadExamples(examplesDir);
21
23
  const server = new McpServer({
22
24
  name: "saglitzdesign",
23
- version: "0.4.0",
25
+ version: "0.5.0",
24
26
  });
25
27
  function docHeader(d) {
26
28
  return `# ${d.title}\n_id: ${d.id} · category: ${d.category} · platform: ${d.platform} · tags: ${d.tags.join(", ")}_\n`;
@@ -369,6 +371,54 @@ server.tool("knowledge_freshness", "Report how fresh each knowledge document is
369
371
  ];
370
372
  return text(lines.join("\n"));
371
373
  });
374
+ // ── Tool 11: generate design tokens ──────────────────────────────────────────
375
+ server.tool("generate_design_tokens", "Turn a design-token spec (semantic colors + optional spacing/radius/type scales) into REAL, ready-to-use artifact files: CSS custom properties, Tailwind v4 @theme, SwiftUI, Jetpack Compose, and W3C DTCG JSON. Deterministic — outputs code, not advice. Use it to give a project one source of truth across web, iOS and Android. Pair with audit_accessibility to verify the palette's contrast.", {
376
+ name: z.string().optional().describe("Token set / brand name (default 'Brand')"),
377
+ colors: z.record(z.string()).describe("Semantic color roles → hex. e.g. {\"primary\":\"#4F46E5\",\"onPrimary\":\"#FFFFFF\",\"surface\":\"#0A0A0B\",\"textPrimary\":\"#F5F5F5\",\"danger\":\"#EF4444\"}"),
378
+ format: z.enum(["css", "tailwind", "swiftui", "compose", "dtcg", "all"]).optional().describe("Output format (default 'all')"),
379
+ spacing: z.array(z.number()).optional().describe("px spacing scale (default 8pt scale 2..96)"),
380
+ radii: z.record(z.number()).optional().describe("radius name→px (default sm/md/lg/xl/full; use 9999 for pill)"),
381
+ fontSizes: z.record(z.number()).optional().describe("type scale name→px (default xs..4xl)"),
382
+ fontFamilies: z.record(z.string()).optional().describe("font role→stack (default sans/mono)"),
383
+ }, async ({ name, colors, format, spacing, radii, fontSizes, fontFamilies }) => {
384
+ const bad = validateColors(colors);
385
+ if (bad.length)
386
+ return text(`Invalid hex value(s): ${bad.join(", ")}. Use #RGB, #RRGGBB, or #RRGGBBAA.`);
387
+ if (Object.keys(colors).length === 0)
388
+ return text("Provide at least one color role in `colors`.");
389
+ const spec = {
390
+ name: name || "Brand",
391
+ colors,
392
+ spacing: spacing && spacing.length ? spacing : DEFAULT_SPACING,
393
+ radii: radii && Object.keys(radii).length ? radii : DEFAULT_RADII,
394
+ fontSizes: fontSizes && Object.keys(fontSizes).length ? fontSizes : DEFAULT_FONT_SIZES,
395
+ fontFamilies: fontFamilies && Object.keys(fontFamilies).length ? fontFamilies : DEFAULT_FONT_FAMILIES,
396
+ };
397
+ return text(generateTokens(spec, format ?? "all"));
398
+ });
399
+ // ── Tool 12: accessibility audit ─────────────────────────────────────────────
400
+ server.tool("audit_accessibility", "Deterministic design-time accessibility checks: WCAG 2.2 color-contrast ratios for text/UI color pairs, and minimum tap/target sizes per platform (iOS 44pt, Android 48dp, web 24px min / 44 recommended). Returns exact ratios, pass/fail, and fixes — the machine-verifiable slice of a11y you can run before code. For keyboard/screen-reader/Dynamic Type checks, see get_design_doc('accessibility').", {
401
+ contrast_pairs: z.array(z.object({
402
+ foreground: z.string().describe("text/element hex"),
403
+ background: z.string().describe("background hex"),
404
+ label: z.string().optional().describe("what this is, e.g. 'body text on surface'"),
405
+ large_text: z.boolean().optional().describe("true if ≥24px or ≥18.66px bold (threshold drops to 3:1)"),
406
+ ui_component: z.boolean().optional().describe("true for non-text UI: borders, icons, focus rings (3:1)"),
407
+ })).optional().describe("Color pairs to check for contrast"),
408
+ tap_targets: z.array(z.object({
409
+ label: z.string().optional(),
410
+ width: z.number().describe("width in pt/dp/px"),
411
+ height: z.number().describe("height in pt/dp/px"),
412
+ platform: z.enum(["ios", "android", "web"]).optional().describe("default web"),
413
+ })).optional().describe("Interactive targets to check for minimum size"),
414
+ }, async ({ contrast_pairs, tap_targets }) => {
415
+ const pairs = (contrast_pairs ?? []);
416
+ const targets = (tap_targets ?? []);
417
+ if (pairs.length === 0 && targets.length === 0) {
418
+ return text("Provide `contrast_pairs` and/or `tap_targets` to audit. Example: {\"contrast_pairs\":[{\"foreground\":\"#6B7280\",\"background\":\"#FFFFFF\",\"label\":\"muted text\"}]}");
419
+ }
420
+ return text(contrastReport(pairs, targets));
421
+ });
372
422
  // ── prompts (user-invocable build/review/redesign workflows) ─────────────────
373
423
  registerPrompts(server, {
374
424
  brief: z
package/dist/prompts.js CHANGED
@@ -97,6 +97,43 @@ Finish with: screens built, how to run/preview, critique scores, platform-fit no
97
97
  },
98
98
  ];
99
99
  const ACTION_PROMPTS = [
100
+ {
101
+ name: "critique_screenshot",
102
+ title: "Critique a screenshot (SaglitzDesign)",
103
+ description: "Grounded, reproducible visual critique of an attached UI screenshot against the fixed 0–40 rubric — cites specific elements, no padding.",
104
+ build: (brief) => `Critique the **attached UI screenshot**${brief ? ` (context: ${brief})` : ""} as a rigorous senior designer, using the SaglitzDesign method.
105
+
106
+ ${TOOLS_NOTE}
107
+
108
+ ## Method — avoid the failure modes of typical AI critique
109
+ Research shows most AI critiques (a) hallucinate issues inconsistently, (b) pad the list to look thorough, and (c) critique a text description instead of the actual pixels. Do NOT do these. Instead:
110
+
111
+ 1. **Look at the image first.** Describe what you actually see (layout, hierarchy, the primary action, states shown) before judging. If you're unsure what an element is, say so — don't invent.
112
+ 2. **Apply the fixed rubric.** Call get_design_doc("design-critique-scoring") and score each of the 10 heuristics 0–4 for a total /40. Use the SAME rubric every time so scores are reproducible.
113
+ 3. **Cite specific elements.** Every finding must point to a concrete element ("the secondary 'Learn more' button competes with the primary CTA — two filled buttons"), not generic advice.
114
+ 4. **No padding.** Report only real issues. If the screen is genuinely good, a short list is the correct answer — do not manufacture findings to seem thorough.
115
+ 5. **Rank by severity P0→P3** and give one concrete fix per finding, citing the SaglitzDesign rule/doc it comes from.
116
+ 6. If it's a known screen type, also run the matching design_review_checklist and get_design_examples to compare against how top apps handle it.
117
+
118
+ Output: the /40 score with per-heuristic line, then findings ranked by severity (element → problem → why (cite rule) → fix), then the 3 highest-impact changes. Keep it tight and specific.`,
119
+ },
120
+ {
121
+ name: "review_paywall",
122
+ title: "Review a paywall / onboarding (SaglitzDesign)",
123
+ description: "Score a paywall or subscription onboarding against real RevenueCat 2026 conversion benchmarks and paywall-anatomy rules.",
124
+ build: (brief) => `Review this **paywall / subscription onboarding**${brief ? `: ${brief}` : ""} using the SaglitzDesign method and real 2026 benchmarks.
125
+
126
+ ${TOOLS_NOTE}
127
+
128
+ ## Method
129
+ 1. Load the data: get_design_doc("paywall-benchmarks") (RevenueCat 2026: hard paywall ~10.7% vs freemium ~2.1%; 17–32 day trials convert 42.5% vs 25.5% for <4 days; 55% of 3-day-trial cancels happen Day 0; Android involuntary churn ~2.2× iOS) and get_design_doc("onboarding-paywall") for anatomy.
130
+ 2. If given a screenshot, look at it and describe the actual paywall (model, plans, trial, price placement, CTA, trust copy). If given a description, work from that; ask up to 3 questions only if a benchmark-critical fact is missing (model, trial length, platform).
131
+ 3. Score it against the review rubric in paywall-benchmarks.md — each item pass/fail with the benchmark it maps to.
132
+ 4. Estimate where it likely leaves conversion on the table (e.g. "≤4-day trial → ~40% relative conversion lost vs a 14–30 day trial").
133
+ 5. Give prioritized, concrete fixes (model choice, trial length, price placement, trial reminder, Android dunning, trust microcopy), each tied to a benchmark number.
134
+
135
+ Output: a pass/fail scorecard, the top 3 conversion risks with their benchmark impact, and the exact changes to make. Be specific and numeric — no generic "add social proof" advice.`,
136
+ },
100
137
  {
101
138
  name: "design_review",
102
139
  title: "Design review (SaglitzDesign)",
package/dist/tokens.js ADDED
@@ -0,0 +1,148 @@
1
+ // Deterministic design-token artifact generator.
2
+ // One source spec → CSS variables, Tailwind v4, SwiftUI, Jetpack Compose, DTCG JSON.
3
+ // Fills the gap where Style Dictionary v4 doesn't yet emit the stable DTCG 2025.10 format.
4
+ export const DEFAULT_SPACING = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96];
5
+ export const DEFAULT_RADII = { sm: 6, md: 10, lg: 16, xl: 24, full: 9999 };
6
+ export const DEFAULT_FONT_SIZES = { xs: 12, sm: 14, base: 16, lg: 18, xl: 20, "2xl": 24, "3xl": 30, "4xl": 40 };
7
+ export const DEFAULT_FONT_FAMILIES = {
8
+ sans: "Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
9
+ mono: "ui-monospace, SFMono-Regular, Menlo, monospace",
10
+ };
11
+ const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
12
+ export function normalizeHex(hex) {
13
+ if (!HEX_RE.test(hex))
14
+ return null;
15
+ let h = hex.slice(1);
16
+ if (h.length === 3)
17
+ h = h.split("").map((c) => c + c).join("");
18
+ if (h.length === 4)
19
+ h = h.split("").map((c) => c + c).join("");
20
+ return "#" + h.toLowerCase();
21
+ }
22
+ function hexParts(hex) {
23
+ const h = normalizeHex(hex).slice(1);
24
+ return {
25
+ r: parseInt(h.slice(0, 2), 16),
26
+ g: parseInt(h.slice(2, 4), 16),
27
+ b: parseInt(h.slice(4, 6), 16),
28
+ a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1,
29
+ };
30
+ }
31
+ function kebab(s) {
32
+ return s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
33
+ }
34
+ function pascal(s) {
35
+ return s.replace(/(^|[-_\s]+)([a-zA-Z0-9])/g, (_, __, c) => c.toUpperCase());
36
+ }
37
+ function camel(s) {
38
+ const p = pascal(s);
39
+ return p.charAt(0).toLowerCase() + p.slice(1);
40
+ }
41
+ // ── CSS custom properties ────────────────────────────────────────────────────
42
+ function toCss(s) {
43
+ const lines = [":root {"];
44
+ lines.push(" /* color */");
45
+ for (const [k, v] of Object.entries(s.colors))
46
+ lines.push(` --color-${kebab(k)}: ${normalizeHex(v)};`);
47
+ lines.push(" /* spacing */");
48
+ s.spacing.forEach((v) => lines.push(` --space-${v}: ${v}px;`));
49
+ lines.push(" /* radius */");
50
+ for (const [k, v] of Object.entries(s.radii))
51
+ lines.push(` --radius-${kebab(k)}: ${v >= 9999 ? "9999px" : v + "px"};`);
52
+ lines.push(" /* font size */");
53
+ for (const [k, v] of Object.entries(s.fontSizes))
54
+ lines.push(` --text-${kebab(k)}: ${(v / 16).toFixed(4).replace(/\.?0+$/, "")}rem;`);
55
+ lines.push(" /* font family */");
56
+ for (const [k, v] of Object.entries(s.fontFamilies))
57
+ lines.push(` --font-${kebab(k)}: ${v};`);
58
+ lines.push("}");
59
+ return lines.join("\n");
60
+ }
61
+ // ── Tailwind v4 (@theme) ─────────────────────────────────────────────────────
62
+ function toTailwind(s) {
63
+ const lines = ["@theme {"];
64
+ for (const [k, v] of Object.entries(s.colors))
65
+ lines.push(` --color-${kebab(k)}: ${normalizeHex(v)};`);
66
+ s.spacing.forEach((v) => lines.push(` --spacing-${v}: ${v}px;`));
67
+ for (const [k, v] of Object.entries(s.radii))
68
+ lines.push(` --radius-${kebab(k)}: ${v >= 9999 ? "9999px" : v + "px"};`);
69
+ for (const [k, v] of Object.entries(s.fontSizes))
70
+ lines.push(` --text-${kebab(k)}: ${(v / 16).toFixed(4).replace(/\.?0+$/, "")}rem;`);
71
+ for (const [k, v] of Object.entries(s.fontFamilies))
72
+ lines.push(` --font-${kebab(k)}: ${v};`);
73
+ lines.push("}");
74
+ return lines.join("\n");
75
+ }
76
+ // ── SwiftUI ──────────────────────────────────────────────────────────────────
77
+ function toSwiftUI(s) {
78
+ const lines = ["import SwiftUI", "", `enum ${pascal(s.name)}Tokens {`];
79
+ lines.push(" // Colors");
80
+ for (const [k, v] of Object.entries(s.colors)) {
81
+ const { r, g, b, a } = hexParts(v);
82
+ lines.push(` static let ${camel(k)} = Color(.sRGB, red: ${(r / 255).toFixed(3)}, green: ${(g / 255).toFixed(3)}, blue: ${(b / 255).toFixed(3)}, opacity: ${a.toFixed(3)})`);
83
+ }
84
+ lines.push(" // Spacing (pt)");
85
+ s.spacing.forEach((v) => lines.push(` static let space${v}: CGFloat = ${v}`));
86
+ lines.push(" // Corner radius (pt)");
87
+ for (const [k, v] of Object.entries(s.radii))
88
+ lines.push(` static let radius${pascal(k)}: CGFloat = ${v >= 9999 ? 9999 : v}`);
89
+ lines.push(" // Font size (pt)");
90
+ for (const [k, v] of Object.entries(s.fontSizes))
91
+ lines.push(` static let text${pascal(k)}: CGFloat = ${v}`);
92
+ lines.push("}");
93
+ return lines.join("\n");
94
+ }
95
+ // ── Jetpack Compose ──────────────────────────────────────────────────────────
96
+ function toCompose(s) {
97
+ const lines = ["import androidx.compose.ui.graphics.Color", "import androidx.compose.ui.unit.dp", "import androidx.compose.ui.unit.sp", "", `object ${pascal(s.name)}Tokens {`];
98
+ lines.push(" // Colors (0xAARRGGBB)");
99
+ for (const [k, v] of Object.entries(s.colors)) {
100
+ const { r, g, b, a } = hexParts(v);
101
+ const argb = ((Math.round(a * 255) << 24) | (r << 16) | (g << 8) | b) >>> 0;
102
+ lines.push(` val ${camel(k)} = Color(0x${argb.toString(16).padStart(8, "0").toUpperCase()})`);
103
+ }
104
+ lines.push(" // Spacing");
105
+ s.spacing.forEach((v) => lines.push(` val space${v} = ${v}.dp`));
106
+ lines.push(" // Corner radius");
107
+ for (const [k, v] of Object.entries(s.radii))
108
+ lines.push(` val radius${pascal(k)} = ${v >= 9999 ? 9999 : v}.dp`);
109
+ lines.push(" // Font size");
110
+ for (const [k, v] of Object.entries(s.fontSizes))
111
+ lines.push(` val text${pascal(k)} = ${v}.sp`);
112
+ lines.push("}");
113
+ return lines.join("\n");
114
+ }
115
+ // ── DTCG JSON (W3C Design Tokens Community Group, stable 2025.10) ─────────────
116
+ function toDtcg(s) {
117
+ const obj = {
118
+ $description: `${s.name} design tokens (DTCG format)`,
119
+ color: Object.fromEntries(Object.entries(s.colors).map(([k, v]) => [kebab(k), { $type: "color", $value: normalizeHex(v) }])),
120
+ spacing: Object.fromEntries(s.spacing.map((v) => [String(v), { $type: "dimension", $value: { value: v, unit: "px" } }])),
121
+ radius: Object.fromEntries(Object.entries(s.radii).map(([k, v]) => [kebab(k), { $type: "dimension", $value: { value: v, unit: "px" } }])),
122
+ fontSize: Object.fromEntries(Object.entries(s.fontSizes).map(([k, v]) => [kebab(k), { $type: "dimension", $value: { value: v, unit: "px" } }])),
123
+ fontFamily: Object.fromEntries(Object.entries(s.fontFamilies).map(([k, v]) => [kebab(k), { $type: "fontFamily", $value: v.split(",").map((f) => f.trim()) }])),
124
+ };
125
+ return JSON.stringify(obj, null, 2);
126
+ }
127
+ const GENERATORS = {
128
+ css: { label: "CSS custom properties (tokens.css)", lang: "css", fn: toCss },
129
+ tailwind: { label: "Tailwind v4 theme (app.css)", lang: "css", fn: toTailwind },
130
+ swiftui: { label: "SwiftUI (Tokens.swift)", lang: "swift", fn: toSwiftUI },
131
+ compose: { label: "Jetpack Compose (Tokens.kt)", lang: "kotlin", fn: toCompose },
132
+ dtcg: { label: "DTCG JSON (tokens.json)", lang: "json", fn: toDtcg },
133
+ };
134
+ export function generateTokens(spec, format) {
135
+ const formats = format === "all" ? Object.keys(GENERATORS) : [format];
136
+ const blocks = formats.map((f) => {
137
+ const g = GENERATORS[f];
138
+ return `## ${g.label}\n\`\`\`${g.lang}\n${g.fn(spec)}\n\`\`\``;
139
+ });
140
+ return `# ${spec.name} — design tokens\n\n${blocks.join("\n\n")}`;
141
+ }
142
+ export function validateColors(colors) {
143
+ const bad = [];
144
+ for (const [k, v] of Object.entries(colors))
145
+ if (!normalizeHex(v))
146
+ bad.push(`${k}: "${v}"`);
147
+ return bad;
148
+ }
@@ -0,0 +1,58 @@
1
+ ---
2
+ id: paywall-benchmarks
3
+ title: "Paywall & Onboarding Conversion Benchmarks (2026)"
4
+ category: marketing
5
+ platform: mobile
6
+ tags: [paywall, subscription, onboarding, conversion, revenuecat, trial, retention]
7
+ sources: ["https://www.revenuecat.com/state-of-subscription-apps/", "https://www.revenuecat.com/blog/growth/subscription-app-trends-benchmarks-2026/", "https://www.revenuecat.com/blog/engineering/android-paywall-gap/"]
8
+ updated: 2026-07-08
9
+ ---
10
+
11
+ # Paywall & Onboarding Conversion Benchmarks (2026)
12
+
13
+ Real benchmarks from RevenueCat's *State of Subscription Apps 2026* (115k+ apps, $16B+ tracked revenue). Use these to judge whether a paywall/onboarding is competitive — not vibes.
14
+
15
+ ## Headline numbers
16
+
17
+ | Metric | Benchmark | Implication |
18
+ |---|---|---|
19
+ | Hard paywall (signup gate) conversion | **~10.7%** install→trial/purchase | ~5× freemium |
20
+ | Freemium (optional paywall) conversion | **~2.1%** | Higher reach, far lower conversion |
21
+ | Day-60 revenue per install (hard vs freemium) | **$3.09 vs $0.38** | ~8× |
22
+ | Trial length 17–32 days → trial-start→paid | **42.5%** | Longer trials convert better |
23
+ | Trial length <4 days → trial-start→paid | **25.5%** | …yet **46.5% of apps use ≤4-day trials** (mistake) |
24
+ | Share of 3-day-trial cancellations on Day 0 | **55%** (84% by Day 1) | The first session decides everything |
25
+ | iOS Day-35 subscriber retention (median) | **~2.6%** of installs | |
26
+ | Android Day-35 (median) | **~0.9%** | Android lags ~3× |
27
+ | Android involuntary (billing-failure) churn | **~2.2× iOS** | Dunning/grace-period handling matters |
28
+
29
+ ## Design rules derived
30
+
31
+ 1. **Choose the model deliberately.** Hard paywall ≈5× conversion and ≈8× RPI — default to it unless the product's value genuinely requires free exploration first (network/UGC/virality). Freemium is a reach play, not a revenue play.
32
+ 2. **Longer trials win.** Move to 14–30 day trials; ≤4-day trials leave ~40% relative conversion on the table. Pair with a **trial reminder** before charge (trust + fewer chargebacks).
33
+ 3. **Win Day 0.** 55% of cancels happen the first day → the onboarding must deliver the "aha" and the paywall must state value + price clearly in session one. Instrument Day-0 retention as the leading indicator.
34
+ 4. **Show value before the wall when possible**, but don't bury the paywall — hard-paywall apps that clearly present the offer up front convert best.
35
+ 5. **Paywall anatomy that converts** (from `onboarding-paywall` pattern): benefit checklist (≤6 concrete benefits), 1–2 plan cards with the recommended one badged, trial timeline ("today → day 12 reminder → day 14 charge"), price + terms disclosed right next to the CTA, restore-purchases link, single primary CTA.
36
+ 6. **Android: fix involuntary churn.** Grace periods, account hold, billing-retry, and clear payment-update prompts — 2.2× iOS involuntary churn is mostly a dunning-design problem, not intent.
37
+ 7. **Personalized onboarding lifts paywall conversion** only when answers visibly change the experience or the paywall framing; ≤5 steps with a progress indicator.
38
+
39
+ ## Review rubric (score a paywall against this)
40
+
41
+ - [ ] Model fits the product (hard vs freemium) — justified, not accidental
42
+ - [ ] Trial length ≥14 days (or a deliberate reason it's shorter)
43
+ - [ ] Pre-charge trial reminder scheduled
44
+ - [ ] Single primary CTA; price + billing terms adjacent to it
45
+ - [ ] ≤6 concrete benefits, recommended plan badged
46
+ - [ ] Restore purchases + terms/privacy links present
47
+ - [ ] Day-0 activation designed (aha before or at the wall)
48
+ - [ ] Android: grace period + billing-retry + payment-update UX
49
+ - [ ] Trust microcopy ("cancel anytime", "you won't be charged until…")
50
+
51
+ ## Anti-patterns
52
+
53
+ - ≤4-day trials by default (industry's most common, costly mistake).
54
+ - Freemium chosen "to be nice" when the product could hard-gate — leaves ~5× on the table.
55
+ - Paywall with hidden/unclear price, or price far from the CTA.
56
+ - No trial reminder → surprise charges → chargebacks + 1-star reviews.
57
+ - Ignoring Android billing failures (treating churn as intent when it's involuntary).
58
+ - Multi-step personalization that never changes anything downstream.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "saglitzdesign-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "SaglitzDesign — expert MCP server for mobile app & website design: UI, UX, buttons, design languages, SEO and GEO guidance backed by a curated knowledge base and real-world pattern research.",
5
5
  "type": "module",
6
6
  "bin": {