@pixerate/editor 0.161.0 → 0.162.1

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.
Files changed (109) hide show
  1. package/dist/agent/index.cjs +36 -35
  2. package/dist/agent/index.js +7 -6
  3. package/dist/animation/index.cjs +5 -4
  4. package/dist/animation/index.js +4 -3
  5. package/dist/animation-editor/index.cjs +63 -62
  6. package/dist/animation-editor/index.js +7 -6
  7. package/dist/{brushstrokeRender-Q4LI22QV.js → brushstrokeRender-7LXCU7PD.js} +2 -2
  8. package/dist/{brushstrokeRender-5WANQIG4.cjs → brushstrokeRender-VO4ZTWVA.cjs} +5 -5
  9. package/dist/canvas/index.cjs +40 -39
  10. package/dist/canvas/index.js +7 -6
  11. package/dist/chart/index.cjs +88 -87
  12. package/dist/chart/index.js +7 -6
  13. package/dist/{chunk-LQLZHP6F.js → chunk-36OKQDCF.js} +7 -4
  14. package/dist/{chunk-AKDWAWSY.cjs → chunk-3IHNXRUV.cjs} +45 -45
  15. package/dist/{chunk-72BGL3I2.js → chunk-3VLOOO3G.js} +1 -1
  16. package/dist/{chunk-TVO6W2Q6.cjs → chunk-4NGVHHJB.cjs} +17 -17
  17. package/dist/chunk-4VU4CP7R.js +769 -0
  18. package/dist/{chunk-MHEXLG3M.cjs → chunk-5AARIS3R.cjs} +37 -37
  19. package/dist/{chunk-DBIVICAO.js → chunk-7CKPUUN4.js} +1 -1
  20. package/dist/{chunk-57JLBGJ5.cjs → chunk-7YBDRJXT.cjs} +8 -8
  21. package/dist/{chunk-KY3FYKP3.js → chunk-BQWGN5WH.js} +1 -1
  22. package/dist/{chunk-LEUZ4FRC.cjs → chunk-CIPIESX4.cjs} +3 -3
  23. package/dist/chunk-CJE5MOIS.js +347 -0
  24. package/dist/{chunk-5QBRAIVK.js → chunk-CX6JLDFC.js} +3 -3
  25. package/dist/{chunk-R3QY7PPL.js → chunk-DTSTNRPH.js} +1 -1
  26. package/dist/{chunk-6LUYFHHN.cjs → chunk-EQ46F75O.cjs} +51 -51
  27. package/dist/{chunk-UKCJ43YL.cjs → chunk-ES3UEVUT.cjs} +130 -898
  28. package/dist/{chunk-UM3RPYC5.js → chunk-EVPZ5IKN.js} +1 -1
  29. package/dist/{chunk-XNQ5EFB4.cjs → chunk-EY22AY6R.cjs} +16 -16
  30. package/dist/chunk-FKUKJ3XA.cjs +347 -0
  31. package/dist/{chunk-YABNLNY7.js → chunk-GLMP22FT.js} +3 -3
  32. package/dist/{chunk-SUF6WRXR.cjs → chunk-GTELFJRE.cjs} +2 -2
  33. package/dist/{chunk-AY65VQSU.js → chunk-H3OEM3Q2.js} +1 -1
  34. package/dist/{chunk-4YWAEV4W.cjs → chunk-HDGBM7EA.cjs} +26 -26
  35. package/dist/{chunk-H5XX4KHB.js → chunk-HLOKB7WW.js} +5 -3
  36. package/dist/{chunk-QMI5HZWA.cjs → chunk-JCXMJUQO.cjs} +5 -5
  37. package/dist/{chunk-WPPJH6X4.cjs → chunk-LL3H5XUH.cjs} +6 -4
  38. package/dist/{chunk-4T44CJ5K.cjs → chunk-NA4UVVJ4.cjs} +15 -15
  39. package/dist/{chunk-Q6PA362P.js → chunk-NO5QQDHZ.js} +2 -2
  40. package/dist/{chunk-3YDQLQH2.cjs → chunk-PA3IMPPS.cjs} +81 -81
  41. package/dist/{chunk-WNAAWLZ5.js → chunk-QY4KUDMY.js} +2 -2
  42. package/dist/{chunk-AA34BA7G.js → chunk-QYZGBVNC.js} +30 -798
  43. package/dist/chunk-S6X2QRVU.cjs +769 -0
  44. package/dist/{chunk-6UPGM3JB.js → chunk-SET6KJOP.js} +2 -2
  45. package/dist/{chunk-GNK7SESQ.cjs → chunk-V7GWZIZK.cjs} +7 -4
  46. package/dist/{chunk-EYVVIZJ3.cjs → chunk-WTW7QRTS.cjs} +1 -1
  47. package/dist/{chunk-SSCL7QQ4.js → chunk-XDCFJEU7.js} +2 -2
  48. package/dist/{chunk-JGKCC5OF.cjs → chunk-YQ5C5BVK.cjs} +10 -9
  49. package/dist/{chunk-A5UCR2TO.js → chunk-YXKLVD56.js} +10 -9
  50. package/dist/{chunk-45XXVHVG.js → chunk-ZZGPYNS2.js} +1 -1
  51. package/dist/color/index.cjs +6 -2
  52. package/dist/color/index.d.cts +1 -0
  53. package/dist/color/index.d.ts +1 -0
  54. package/dist/color/index.js +28 -24
  55. package/dist/color/suggest.d.cts +12 -0
  56. package/dist/color/suggest.d.ts +12 -0
  57. package/dist/effects/index.cjs +37 -13
  58. package/dist/effects/index.js +29 -5
  59. package/dist/effects/ui.d.cts +20 -3
  60. package/dist/effects/ui.d.ts +20 -3
  61. package/dist/export/index.cjs +6 -5
  62. package/dist/export/index.js +5 -4
  63. package/dist/extensions/index.cjs +9 -8
  64. package/dist/extensions/index.js +8 -7
  65. package/dist/gradient/index.cjs +28 -26
  66. package/dist/gradient/index.js +7 -5
  67. package/dist/image-editor/index.cjs +641 -640
  68. package/dist/image-editor/index.js +7 -6
  69. package/dist/index.cjs +15 -14
  70. package/dist/index.js +9 -8
  71. package/dist/paint/index.cjs +2 -2
  72. package/dist/paint/index.js +1 -1
  73. package/dist/paint-editor/index.cjs +136 -135
  74. package/dist/paint-editor/index.js +8 -7
  75. package/dist/typography/accessibility.d.cts +44 -0
  76. package/dist/typography/accessibility.d.ts +44 -0
  77. package/dist/typography/catalog.d.cts +98 -0
  78. package/dist/typography/catalog.d.ts +98 -0
  79. package/dist/typography/index.cjs +1099 -0
  80. package/dist/typography/index.d.cts +16 -0
  81. package/dist/typography/index.d.ts +16 -0
  82. package/dist/typography/index.js +1099 -0
  83. package/dist/typography/metrics.d.cts +31 -0
  84. package/dist/typography/metrics.d.ts +31 -0
  85. package/dist/typography/picker.d.cts +132 -0
  86. package/dist/typography/picker.d.ts +132 -0
  87. package/dist/typography/registry.d.cts +66 -0
  88. package/dist/typography/registry.d.ts +66 -0
  89. package/dist/typography/scale.d.cts +62 -0
  90. package/dist/typography/scale.d.ts +62 -0
  91. package/dist/typography/segment.d.cts +26 -0
  92. package/dist/typography/segment.d.ts +26 -0
  93. package/dist/typography/stack.d.cts +26 -0
  94. package/dist/typography/stack.d.ts +26 -0
  95. package/dist/typography/tools.d.cts +34 -0
  96. package/dist/typography/tools.d.ts +34 -0
  97. package/dist/typography/types.d.cts +96 -0
  98. package/dist/typography/types.d.ts +96 -0
  99. package/dist/typography/weight.d.cts +13 -0
  100. package/dist/typography/weight.d.ts +13 -0
  101. package/dist/vector/index.cjs +951 -950
  102. package/dist/vector/index.js +6 -5
  103. package/dist/video-editor/index.cjs +5 -4
  104. package/dist/video-editor/index.js +4 -3
  105. package/package.json +13 -3
  106. package/skills/README.md +1 -0
  107. package/skills/pixerate-chart/SKILL.md +92 -9
  108. package/skills/pixerate-paint-editor/SKILL.md +0 -2
  109. package/skills/pixerate-typography/SKILL.md +91 -0
@@ -0,0 +1,1099 @@
1
+ import {
2
+ FONT_PLATFORMS,
3
+ SYSTEM_FONT_CATALOG,
4
+ combineFontProviders,
5
+ createCatalogFontProvider,
6
+ createSystemFontProvider,
7
+ createWebFontProvider,
8
+ ensureFontsLoaded,
9
+ filterFonts,
10
+ findFont,
11
+ fontFaceCss,
12
+ fontHasWeight,
13
+ guessFontCategory,
14
+ isFontWeight,
15
+ nearestFontWeight,
16
+ normalizeFontWeight,
17
+ webFontSummary
18
+ } from "../chunk-CJE5MOIS.js";
19
+ import {
20
+ contrastLevel,
21
+ contrastRatio,
22
+ parseColor,
23
+ suggestAccessibleForeground
24
+ } from "../chunk-4VU4CP7R.js";
25
+ import "../chunk-6ONMQ6WF.js";
26
+
27
+ // src/typography/types.ts
28
+ var FONT_CATEGORIES = ["sans-serif", "serif", "monospace", "display", "handwriting"];
29
+ var FONT_CATEGORY_LABELS = {
30
+ all: "All",
31
+ "sans-serif": "Sans serif",
32
+ serif: "Serif",
33
+ monospace: "Monospace",
34
+ display: "Display",
35
+ handwriting: "Handwriting"
36
+ };
37
+ var FONT_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
38
+ var FONT_STYLES = ["normal", "italic", "oblique"];
39
+
40
+ // src/typography/scale.ts
41
+ var MODULAR_SCALE_RATIOS = {
42
+ "minor-second": 1.067,
43
+ "major-second": 1.125,
44
+ "minor-third": 1.2,
45
+ "major-third": 1.25,
46
+ "perfect-fourth": 1.333,
47
+ "augmented-fourth": 1.414,
48
+ "perfect-fifth": 1.5,
49
+ "golden-ratio": 1.618
50
+ };
51
+ var MODULAR_SCALE_RATIO_NAMES = Object.keys(MODULAR_SCALE_RATIOS);
52
+ var TYPE_HIERARCHY_STEPS = {
53
+ caption: -2,
54
+ "body-sm": -1,
55
+ body: 0,
56
+ h4: 1,
57
+ h3: 2,
58
+ h2: 3,
59
+ h1: 4,
60
+ display: 5
61
+ };
62
+ var round = (n, places = 2) => {
63
+ const f = 10 ** places;
64
+ return Math.round(n * f) / f;
65
+ };
66
+ function getScaleRatio(name) {
67
+ const ratio = MODULAR_SCALE_RATIOS[name];
68
+ if (ratio === void 0) throw new Error(`Unknown scale ratio "${name}"; use one of ${MODULAR_SCALE_RATIO_NAMES.join(", ")} or a number above 1.`);
69
+ return ratio;
70
+ }
71
+ function resolveScaleRatio(ratio) {
72
+ if (typeof ratio === "string") return getScaleRatio(ratio);
73
+ if (!Number.isFinite(ratio) || ratio <= 1 || ratio > 4) throw new Error(`Scale ratio ${ratio} is out of range; use a number above 1 and at most 4 (1.25 is a common choice).`);
74
+ return ratio;
75
+ }
76
+ function recommendedLineHeight(size) {
77
+ if (size <= 16) return 1.5;
78
+ if (size >= 64) return 1.1;
79
+ const t = (size - 16) / 48;
80
+ return Math.round((1.5 - 0.4 * t) * 20) / 20;
81
+ }
82
+ function recommendedLetterSpacing(size) {
83
+ if (size < 14) return 0.01;
84
+ if (size < 24) return 0;
85
+ if (size < 40) return -0.01;
86
+ if (size < 64) return -0.02;
87
+ return -0.03;
88
+ }
89
+ function typeScaleStep(baseSize, ratio, step, name = `step-${step}`) {
90
+ const size = round(baseSize * ratio ** step);
91
+ return { name, step, size, lineHeight: recommendedLineHeight(size), letterSpacing: recommendedLetterSpacing(size) };
92
+ }
93
+ function requireBase(baseSize) {
94
+ if (!Number.isFinite(baseSize) || baseSize <= 0 || baseSize > 512) throw new Error(`Base size ${baseSize} is out of range; use a size in px above 0 and at most 512 (16 is the browser default).`);
95
+ return baseSize;
96
+ }
97
+ function computeTypeScale(baseSize, ratio, steps = [-2, -1, 0, 1, 2, 3, 4, 5]) {
98
+ requireBase(baseSize);
99
+ const r = resolveScaleRatio(ratio);
100
+ return steps.map((step) => {
101
+ if (!Number.isInteger(step) || Math.abs(step) > 12) throw new Error(`Step ${step} must be an integer from -12 to 12.`);
102
+ return typeScaleStep(baseSize, r, step);
103
+ });
104
+ }
105
+ function generateTypeHierarchy(options = {}) {
106
+ const baseSize = requireBase(options.baseSize ?? 16);
107
+ const ratioOption = options.ratio ?? "major-third";
108
+ const ratio = resolveScaleRatio(ratioOption);
109
+ const steps = {};
110
+ for (const [name, step] of Object.entries(TYPE_HIERARCHY_STEPS)) steps[name] = typeScaleStep(baseSize, ratio, step, name);
111
+ return { ratio, ...typeof ratioOption === "string" ? { ratioName: ratioOption } : {}, baseSize, steps };
112
+ }
113
+ function typeHierarchyToCss(hierarchy, options = {}) {
114
+ const prefix = options.prefix ?? "font";
115
+ const root = options.rootFontSize ?? 16;
116
+ const lines = Object.values(hierarchy.steps).flatMap((s) => [
117
+ ` --${prefix}-${s.name}-size: ${round(s.size / root, 4)}rem;`,
118
+ ` --${prefix}-${s.name}-line-height: ${s.lineHeight};`,
119
+ ` --${prefix}-${s.name}-letter-spacing: ${s.letterSpacing}em;`
120
+ ]);
121
+ return `${options.selector ?? ":root"} {
122
+ ${lines.join("\n")}
123
+ }`;
124
+ }
125
+ function computeFluidSize(options) {
126
+ const { minSize, maxSize, minViewport, maxViewport } = options;
127
+ const root = options.rootFontSize ?? 16;
128
+ for (const [k, v] of Object.entries({ minSize, maxSize, minViewport, maxViewport, rootFontSize: root })) {
129
+ if (!Number.isFinite(v) || v <= 0) throw new Error(`${k} must be a positive number of px (got ${v}).`);
130
+ }
131
+ if (maxViewport <= minViewport) throw new Error(`maxViewport (${maxViewport}) must be larger than minViewport (${minViewport}).`);
132
+ const slope = (maxSize - minSize) / (maxViewport - minViewport);
133
+ const intercept = minSize - slope * minViewport;
134
+ const lo = Math.min(minSize, maxSize);
135
+ const hi = Math.max(minSize, maxSize);
136
+ const rem = (px) => `${round(px / root, 4)}rem`;
137
+ const sign = intercept < 0 ? "-" : "+";
138
+ const clampCss = `clamp(${rem(lo)}, ${round(slope * 100, 4)}vw ${sign} ${rem(Math.abs(intercept))}, ${rem(hi)})`;
139
+ return {
140
+ clampCss,
141
+ slope: round(slope, 6),
142
+ intercept: round(intercept, 4),
143
+ evaluateAt: (viewport) => round(Math.min(hi, Math.max(lo, intercept + slope * viewport)), 4)
144
+ };
145
+ }
146
+
147
+ // src/typography/segment.ts
148
+ function segmenter(granularity, locale) {
149
+ const Ctor = Intl.Segmenter;
150
+ return Ctor ? new Ctor(locale, { granularity }) : null;
151
+ }
152
+ function graphemes(text, locale) {
153
+ const seg = segmenter("grapheme", locale);
154
+ return seg ? Array.from(seg.segment(text), (s) => s.segment) : Array.from(text);
155
+ }
156
+ function countGraphemes(text, locale) {
157
+ return graphemes(text, locale).length;
158
+ }
159
+ function rawSegments(text, granularity, locale) {
160
+ const seg = segmenter(granularity, locale);
161
+ if (seg) {
162
+ return Array.from(seg.segment(text), (s) => ({
163
+ text: s.segment,
164
+ start: s.index,
165
+ end: s.index + s.segment.length,
166
+ ...granularity === "word" ? { isWordLike: !!s.isWordLike } : {}
167
+ }));
168
+ }
169
+ const out = [];
170
+ if (granularity === "grapheme") {
171
+ let i = 0;
172
+ for (const ch of text) {
173
+ out.push({ text: ch, start: i, end: i + ch.length });
174
+ i += ch.length;
175
+ }
176
+ return out;
177
+ }
178
+ const re = granularity === "word" ? /[\p{L}\p{N}\p{M}'’_-]+|\s+|[^\p{L}\p{N}\p{M}\s]/gu : /[^.!?]*(?:[.!?]+|$)\s*/g;
179
+ for (const m of text.matchAll(re)) {
180
+ if (!m[0]) continue;
181
+ out.push({
182
+ text: m[0],
183
+ start: m.index,
184
+ end: m.index + m[0].length,
185
+ ...granularity === "word" ? { isWordLike: /[\p{L}\p{N}]/u.test(m[0]) } : {}
186
+ });
187
+ }
188
+ return out;
189
+ }
190
+ function lineSegments(text, maxCharacters, locale) {
191
+ const out = [];
192
+ const re = /\r\n|\n|\r|\u2028|\u2029/g;
193
+ let lineStart = 0;
194
+ const hardLines = [];
195
+ for (const m of text.matchAll(re)) {
196
+ hardLines.push({ start: lineStart, end: m.index });
197
+ lineStart = m.index + m[0].length;
198
+ }
199
+ hardLines.push({ start: lineStart, end: text.length });
200
+ for (const { start, end } of hardLines) {
201
+ const line = text.slice(start, end);
202
+ if (!maxCharacters || countGraphemes(line, locale) <= maxCharacters) {
203
+ out.push({ text: line, start, end });
204
+ continue;
205
+ }
206
+ const words = rawSegments(line, "word", locale);
207
+ let segStart = 0;
208
+ let length = 0;
209
+ let lastEnd = 0;
210
+ for (const w of words) {
211
+ const n = countGraphemes(w.text, locale);
212
+ const isSpace = !w.isWordLike && /^\s+$/.test(w.text);
213
+ if (!isSpace && length > 0 && length + n > maxCharacters) {
214
+ out.push({ text: line.slice(segStart, w.start), start: start + segStart, end: start + w.start });
215
+ segStart = w.start;
216
+ length = 0;
217
+ }
218
+ length += n;
219
+ lastEnd = w.end;
220
+ }
221
+ out.push({ text: line.slice(segStart, lastEnd), start: start + segStart, end: start + lastEnd });
222
+ }
223
+ return out;
224
+ }
225
+ function segmentText(text, granularity, options = {}) {
226
+ if (typeof text !== "string") throw new Error("text must be a string.");
227
+ let segments;
228
+ if (granularity === "line") {
229
+ if (options.maxCharacters !== void 0 && (!Number.isInteger(options.maxCharacters) || options.maxCharacters < 1)) {
230
+ throw new Error(`maxCharacters must be a positive integer (got ${options.maxCharacters}).`);
231
+ }
232
+ segments = lineSegments(text, options.maxCharacters, options.locale);
233
+ } else if (granularity === "grapheme" || granularity === "word" || granularity === "sentence") {
234
+ segments = rawSegments(text, granularity, options.locale);
235
+ if (granularity === "word" && options.wordsOnly) segments = segments.filter((s) => s.isWordLike);
236
+ } else {
237
+ throw new Error(`Unknown granularity "${String(granularity)}"; use grapheme, word, sentence or line.`);
238
+ }
239
+ return segments.map((s, index) => ({ ...s, index }));
240
+ }
241
+
242
+ // src/typography/metrics.ts
243
+ var OPTIMAL_MEASURE = { min: 45, max: 75 };
244
+ var RECOMMENDED_LINE_HEIGHT = 1.5;
245
+ var READING_WORDS_PER_MINUTE = 238;
246
+ var ASSUMED_NORMAL_LINE_HEIGHT = 1.2;
247
+ var CHARACTER_WIDTH = {
248
+ "sans-serif": 0.5,
249
+ serif: 0.47,
250
+ monospace: 0.6,
251
+ display: 0.55,
252
+ handwriting: 0.5
253
+ };
254
+ var round2 = (n, places = 1) => Math.round(n * 10 ** places) / 10 ** places;
255
+ function estimateCharactersPerLine(width, font) {
256
+ if (!(width > 0) || !(font.size > 0)) throw new Error("width and size must be positive numbers of px.");
257
+ const advance = font.size * (CHARACTER_WIDTH[font.category ?? "sans-serif"] + (font.letterSpacing ?? 0));
258
+ return Math.max(1, Math.floor(width / advance));
259
+ }
260
+ var WORD = /[\p{L}\p{N}][\p{L}\p{N}\p{M}'’-]*/gu;
261
+ function auditTextReadability(text, font = {}) {
262
+ if (typeof text !== "string") throw new Error("text must be a string.");
263
+ const issues = [];
264
+ const words = text.match(WORD) ?? [];
265
+ const lines = text.split(/\r\n|\n|\r/).map((l) => l.trim()).filter(Boolean);
266
+ let characters;
267
+ let wordsPerLine;
268
+ let source;
269
+ if (font.width !== void 0 && font.size !== void 0) {
270
+ source = "width";
271
+ characters = estimateCharactersPerLine(font.width, { size: font.size, category: font.category, letterSpacing: font.letterSpacing });
272
+ const avgWordLength = words.length ? countGraphemes(words.join(" ")) / words.length : 5;
273
+ wordsPerLine = round2(characters / (avgWordLength + 1));
274
+ } else {
275
+ source = "lines";
276
+ characters = lines.length ? round2(lines.reduce((n, l) => n + countGraphemes(l), 0) / lines.length) : 0;
277
+ wordsPerLine = lines.length ? round2(lines.reduce((n, l) => n + (l.match(WORD)?.length ?? 0), 0) / lines.length) : 0;
278
+ }
279
+ const status = characters < OPTIMAL_MEASURE.min ? "too-short" : characters > OPTIMAL_MEASURE.max ? "too-long" : "optimal";
280
+ let lineRecommendation;
281
+ if (status === "too-long") {
282
+ lineRecommendation = `Lines average ${characters} characters; keep body text to ${OPTIMAL_MEASURE.min}\u2013${OPTIMAL_MEASURE.max} (about 66 is ideal) by narrowing the column, e.g. max-width: 66ch.`;
283
+ issues.push(lineRecommendation);
284
+ } else if (status === "too-short" && source === "width") {
285
+ lineRecommendation = `Lines fit about ${characters} characters; short lines break reading rhythm. Widen the column or reduce the font size to reach ${OPTIMAL_MEASURE.min}\u2013${OPTIMAL_MEASURE.max}.`;
286
+ issues.push(lineRecommendation);
287
+ } else if (status === "too-short" && lines.length > 1) {
288
+ lineRecommendation = `Lines average ${characters} characters; for body text aim for ${OPTIMAL_MEASURE.min}\u2013${OPTIMAL_MEASURE.max}. Short lines are fine for headings, captions and lists.`;
289
+ issues.push(lineRecommendation);
290
+ } else if (status === "too-short") {
291
+ lineRecommendation = `A single line of ${characters} characters: the ${OPTIMAL_MEASURE.min}\u2013${OPTIMAL_MEASURE.max} range applies to wrapped paragraphs; pass width and size to estimate their measure.`;
292
+ }
293
+ const assumed = font.lineHeight === void 0;
294
+ const ratio = font.lineHeight ?? ASSUMED_NORMAL_LINE_HEIGHT;
295
+ const meetsRecommended = ratio >= RECOMMENDED_LINE_HEIGHT;
296
+ const spacingRecommendation = meetsRecommended ? void 0 : `Line height ${ratio}${assumed ? " (assumed browser normal)" : ""} is below ${RECOMMENDED_LINE_HEIGHT}; set at least ${RECOMMENDED_LINE_HEIGHT} for paragraphs (WCAG 1.4.8), and make sure the layout survives 1.5 line height, 0.12em letter spacing and 0.16em word spacing (WCAG 1.4.12).`;
297
+ if (spacingRecommendation && (lines.length > 1 || source === "width" || words.length > 12)) issues.push(spacingRecommendation);
298
+ const letters = text.match(/\p{L}/gu) ?? [];
299
+ const allCaps = letters.length >= 2 && !/\p{Ll}/u.test(text) && /\p{Lu}/u.test(text);
300
+ if (allCaps && words.length > 5) issues.push("Long runs of capitals are slower to read; set body text in sentence case and use text-transform or small caps only for short labels.");
301
+ if (font.size !== void 0 && font.size < 12) issues.push(`Font size ${font.size}px is below 12px; most body text should be 16px or more.`);
302
+ if (font.letterSpacing !== void 0 && font.letterSpacing < -0.05) issues.push(`Letter spacing ${font.letterSpacing}em is very tight; letters may collide below -0.05em.`);
303
+ return {
304
+ lineLength: { characters, words: wordsPerLine, source, status, ...lineRecommendation ? { recommendation: lineRecommendation } : {} },
305
+ lineSpacing: { ratio, assumed, meetsRecommended, ...spacingRecommendation ? { recommendation: spacingRecommendation } : {} },
306
+ allCaps,
307
+ wordCount: words.length,
308
+ estimatedReadingTimeSeconds: Math.round(words.length / READING_WORDS_PER_MINUTE * 60),
309
+ issues
310
+ };
311
+ }
312
+
313
+ // src/typography/stack.ts
314
+ var GENERIC_FONT_FAMILIES = [
315
+ "serif",
316
+ "sans-serif",
317
+ "monospace",
318
+ "cursive",
319
+ "fantasy",
320
+ "system-ui",
321
+ "ui-serif",
322
+ "ui-sans-serif",
323
+ "ui-monospace",
324
+ "ui-rounded",
325
+ "math",
326
+ "emoji",
327
+ "fangsong"
328
+ ];
329
+ var FONT_STACKS = {
330
+ "system-ui": 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
331
+ "humanist-sans": 'Optima, Candara, Calibri, "Trebuchet MS", "Noto Sans", sans-serif',
332
+ "geometric-sans": '"Century Gothic", Futura, "Avenir Next", Avenir, "URW Gothic L", sans-serif',
333
+ "editorial-serif": 'Charter, "Bitstream Charter", "Sitka Text", Cambria, Georgia, serif',
334
+ "transitional-serif": 'Baskerville, "Baskerville Old Face", "Hoefler Text", Garamond, "Times New Roman", Times, serif',
335
+ "code-mono": 'ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, "DejaVu Sans Mono", monospace',
336
+ handwriting: '"Segoe Print", "Bradley Hand", Chilanka, "Comic Sans MS", cursive',
337
+ display: 'Impact, Haettenschweiler, "Arial Narrow Bold", "Franklin Gothic Bold", sans-serif'
338
+ };
339
+ var FONT_STACK_NAMES = Object.keys(FONT_STACKS);
340
+ var CATEGORY_FALLBACK_STACK = {
341
+ "sans-serif": "system-ui",
342
+ serif: "editorial-serif",
343
+ monospace: "code-mono",
344
+ display: "system-ui",
345
+ handwriting: "handwriting"
346
+ };
347
+ var GENERIC = new Set(GENERIC_FONT_FAMILIES);
348
+ function parseFontFamily(stack) {
349
+ const out = [];
350
+ let current = "";
351
+ let quote = null;
352
+ let quoted = false;
353
+ const push = () => {
354
+ const name = quoted ? current : current.trim().replace(/\s+/g, " ");
355
+ if (name) out.push(name);
356
+ current = "";
357
+ quoted = false;
358
+ };
359
+ for (let i = 0; i < stack.length; i++) {
360
+ const ch = stack[i];
361
+ if (ch === "\\" && i + 1 < stack.length) {
362
+ current += stack[++i];
363
+ continue;
364
+ }
365
+ if (quote) {
366
+ if (ch === quote) quote = null;
367
+ else current += ch;
368
+ continue;
369
+ }
370
+ if (ch === '"' || ch === "'") {
371
+ quote = ch;
372
+ quoted = true;
373
+ current = "";
374
+ continue;
375
+ }
376
+ if (ch === ",") {
377
+ push();
378
+ continue;
379
+ }
380
+ if (quoted) continue;
381
+ current += ch;
382
+ }
383
+ push();
384
+ return out;
385
+ }
386
+ function quoteFontFamily(name) {
387
+ if (GENERIC.has(name)) return name;
388
+ const ident = /^-?[_a-zA-Z\u00a0-\uffff][_a-zA-Z0-9\u00a0-\uffff-]*$/;
389
+ if (!name.includes(" ") && ident.test(name)) return name;
390
+ return `"${name.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
391
+ }
392
+ function formatFontFamily(families) {
393
+ const seen = /* @__PURE__ */ new Set();
394
+ const out = [];
395
+ for (const f of families) {
396
+ const key = f.toLowerCase();
397
+ if (!f || seen.has(key)) continue;
398
+ seen.add(key);
399
+ out.push(quoteFontFamily(f));
400
+ }
401
+ return out.join(", ");
402
+ }
403
+ function resolveFontFallback(family, category) {
404
+ const families = parseFontFamily(family);
405
+ if (!families.length) throw new Error("family must name at least one font.");
406
+ if (GENERIC.has(families[families.length - 1])) return formatFontFamily(families);
407
+ const cat = category ?? guessFontCategory(families[0]);
408
+ return formatFontFamily([...families, ...parseFontFamily(FONT_STACKS[CATEGORY_FALLBACK_STACK[cat]])]);
409
+ }
410
+
411
+ // src/typography/accessibility.ts
412
+ var LARGE_TEXT_PX = 24;
413
+ var LARGE_BOLD_TEXT_PX = 14 * 4 / 3;
414
+ function isLargeText(font) {
415
+ const bold = normalizeFontWeight(font.weight) >= 700;
416
+ return font.size >= LARGE_TEXT_PX || bold && font.size >= LARGE_BOLD_TEXT_PX - 0.01;
417
+ }
418
+ function checkTypographyAccessibility(options) {
419
+ const { font } = options;
420
+ if (!Number.isFinite(font.size) || font.size <= 0) throw new Error(`Font size ${font.size} must be a positive number of px.`);
421
+ const fg = parseColor(options.foreground);
422
+ if (!fg) throw new Error(`"${options.foreground}" is not a colour I can read; use hex (#2a4b8d), rgb(), hsl(), oklch() or a CSS colour name.`);
423
+ const bg = parseColor(options.background);
424
+ if (!bg) throw new Error(`"${options.background}" is not a colour I can read; use hex (#ffffff), rgb(), hsl(), oklch() or a CSS colour name.`);
425
+ const large = isLargeText(font);
426
+ const ratio = Math.round(contrastRatio(fg, bg) * 100) / 100;
427
+ const level = contrastLevel(ratio, { large });
428
+ const passesAA = level !== "fail";
429
+ const bold = normalizeFontWeight(font.weight) >= 700;
430
+ const issues = [];
431
+ const required = large ? { AA: 3, AAA: 4.5 } : { AA: 4.5, AAA: 7 };
432
+ if (!passesAA) issues.push(`Contrast ${ratio}:1 is below the ${required.AA}:1 AA minimum for ${large ? "large" : "normal"} text.`);
433
+ if (font.size < 12) issues.push(`${font.size}px text is hard to read at any contrast; use 12px or more (16px for body text).`);
434
+ const weight = normalizeFontWeight(font.weight);
435
+ if (weight <= 300 && font.size < 24) issues.push(`Weight ${weight} at ${font.size}px has thin strokes that lower effective contrast; use 400 or heavier below 24px.`);
436
+ const suggestion = passesAA ? void 0 : suggestAccessibleForeground(fg, bg, { large });
437
+ return {
438
+ ratio,
439
+ large,
440
+ level,
441
+ passesAA,
442
+ passesAAA: level === "AAA",
443
+ required,
444
+ ...suggestion ? { suggestion } : {},
445
+ largeTextMinSize: Math.round((bold ? LARGE_BOLD_TEXT_PX : LARGE_TEXT_PX) * 100) / 100,
446
+ issues
447
+ };
448
+ }
449
+
450
+ // src/typography/picker.ts
451
+ var ALL_WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];
452
+ function offeredWeights(font) {
453
+ if (!font || !font.weights.length) return [...ALL_WEIGHTS];
454
+ const ws = font.weights.map((w) => normalizeFontWeight(w));
455
+ if (font.isVariable) return ALL_WEIGHTS.filter((w) => w >= Math.min(...ws) && w <= Math.max(...ws));
456
+ return [...new Set(ws)].sort((a, b) => a - b);
457
+ }
458
+ var DEFAULT_PREVIEW_TEXT = "The quick brown fox jumps over the lazy dog";
459
+ var clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
460
+ var FontPickerModel = class {
461
+ state;
462
+ listeners = /* @__PURE__ */ new Set();
463
+ valueListeners = /* @__PURE__ */ new Set();
464
+ recentFamilies;
465
+ destroyed = false;
466
+ provider;
467
+ maxRecent;
468
+ loadOnSelect;
469
+ onChange;
470
+ constructor(options = {}) {
471
+ this.provider = options.provider;
472
+ this.maxRecent = options.maxRecent ?? 8;
473
+ this.loadOnSelect = options.loadOnSelect ?? true;
474
+ this.onChange = options.onChange;
475
+ this.recentFamilies = [...options.recent ?? []];
476
+ const fonts = [...options.fonts ?? []];
477
+ const v = options.value ?? {};
478
+ const family = v.family ?? fonts[0]?.family ?? "system-ui";
479
+ const known = findFont(fonts, family);
480
+ this.state = this.derive({
481
+ value: {
482
+ family: known?.family ?? family,
483
+ weight: normalizeFontWeight(v.weight),
484
+ style: v.style ?? "normal",
485
+ size: v.size ?? 16,
486
+ lineHeight: v.lineHeight ?? 1.5,
487
+ letterSpacing: v.letterSpacing ?? 0,
488
+ ...v.category ?? known?.category ? { category: v.category ?? known?.category } : {}
489
+ },
490
+ category: "all",
491
+ query: "",
492
+ fonts,
493
+ results: [],
494
+ recent: [],
495
+ highlighted: -1,
496
+ previewText: options.previewText ?? DEFAULT_PREVIEW_TEXT,
497
+ loading: false,
498
+ loadStatus: {}
499
+ });
500
+ }
501
+ /** The current state. The same object is returned until something changes. */
502
+ getState = () => this.state;
503
+ /** Calls `listener` after every change; returns an unsubscribe function. */
504
+ subscribe = (listener) => {
505
+ this.listeners.add(listener);
506
+ return () => this.listeners.delete(listener);
507
+ };
508
+ /**
509
+ * Calls `listener` when the value changes through the picker or its agent tools (not through
510
+ * `setValue`, so a controlled component does not echo its own updates). Returns an unsubscribe.
511
+ */
512
+ onValueChange = (listener) => {
513
+ this.valueListeners.add(listener);
514
+ return () => this.valueListeners.delete(listener);
515
+ };
516
+ /** The selected value as a {@link FontSpec}. */
517
+ get value() {
518
+ const { category, ...rest } = this.state.value;
519
+ return { ...rest, ...category ? { category } : {} };
520
+ }
521
+ /** The selected family's summary, if it is in `fonts`. */
522
+ get selectedFont() {
523
+ return findFont(this.state.fonts, this.state.value.family);
524
+ }
525
+ /** Weights the selected family offers (all nine when unknown or variable). */
526
+ get availableWeights() {
527
+ return offeredWeights(this.selectedFont);
528
+ }
529
+ /** Styles the selected family offers (`normal` when unknown). */
530
+ get availableStyles() {
531
+ return this.selectedFont?.styles.length ? [...this.selectedFont.styles] : ["normal", "italic"];
532
+ }
533
+ /** CSS for the selection: a fallback-resolved `font-family` plus the other properties. */
534
+ get css() {
535
+ const v = this.state.value;
536
+ return {
537
+ fontFamily: resolveFontFallback(v.family, v.category ?? this.selectedFont?.category),
538
+ fontWeight: String(v.weight),
539
+ fontStyle: v.style,
540
+ fontSize: `${v.size}px`,
541
+ lineHeight: String(v.lineHeight),
542
+ letterSpacing: `${v.letterSpacing}em`
543
+ };
544
+ }
545
+ /** A `font-family` value for previewing any family in a list. */
546
+ familyCss(font) {
547
+ return typeof font === "string" ? resolveFontFallback(font) : resolveFontFallback(font.family, font.category);
548
+ }
549
+ /** Fetches fonts from the provider. Keeps the current list when it fails. */
550
+ async refresh() {
551
+ if (!this.provider) return this.state.fonts;
552
+ this.set({ loading: true, error: void 0 });
553
+ try {
554
+ const fonts = await this.provider.listFonts();
555
+ if (this.destroyed) return fonts;
556
+ const known = findFont(fonts, this.state.value.family);
557
+ this.set({ fonts, loading: false, value: known ? { ...this.state.value, family: known.family, category: known.category } : this.state.value });
558
+ return fonts;
559
+ } catch (e) {
560
+ this.set({ loading: false, error: e instanceof Error ? e.message : String(e) });
561
+ return this.state.fonts;
562
+ }
563
+ }
564
+ setQuery(query) {
565
+ this.set({ query, highlighted: -1 });
566
+ }
567
+ setCategory(category) {
568
+ if (category !== "all" && !["sans-serif", "serif", "monospace", "display", "handwriting"].includes(category)) {
569
+ throw new Error(`Unknown category "${category}"; use all, sans-serif, serif, monospace, display or handwriting.`);
570
+ }
571
+ this.set({ category, highlighted: -1 });
572
+ }
573
+ setPreviewText(previewText) {
574
+ this.set({ previewText });
575
+ }
576
+ /**
577
+ * Selects a family. Throws when `fonts` is non-empty and does not list it. Keeps the weight if
578
+ * the family has it, else picks the nearest one; keeps the style if offered, else `normal`.
579
+ */
580
+ selectFamily(family) {
581
+ const font = findFont(this.state.fonts, family);
582
+ if (!font && this.state.fonts.length) throw new Error(`Font "${family}" is not available; pick one of the listed families (search with a query).`);
583
+ const name = font?.family ?? family.trim();
584
+ if (!name) throw new Error("family must not be empty.");
585
+ const weight = font ? nearestFontWeight(this.state.value.weight, offeredWeights(font)) : this.state.value.weight;
586
+ const style = !font || font.styles.includes(this.state.value.style) ? this.state.value.style : "normal";
587
+ this.recentFamilies = [name, ...this.recentFamilies.filter((f) => f.toLowerCase() !== name.toLowerCase())].slice(0, this.maxRecent);
588
+ const { category: _old, ...rest } = this.state.value;
589
+ this.commit({ ...rest, family: name, weight, style, ...font ? { category: font.category } : {} });
590
+ if (this.loadOnSelect) void this.loadFamily(name);
591
+ }
592
+ /** Sets the weight; snaps to the nearest weight the family offers. */
593
+ setWeight(weight) {
594
+ this.commit({ ...this.state.value, weight: nearestFontWeight(normalizeFontWeight(weight), this.availableWeights) });
595
+ }
596
+ setStyle(style) {
597
+ if (!["normal", "italic", "oblique"].includes(style)) throw new Error(`Unknown style "${style}"; use normal, italic or oblique.`);
598
+ this.commit({ ...this.state.value, style });
599
+ }
600
+ /** Font size in px, clamped to 1–1000. */
601
+ setSize(size) {
602
+ if (!Number.isFinite(size)) return;
603
+ this.commit({ ...this.state.value, size: clamp(Math.round(size * 100) / 100, 1, 1e3) });
604
+ }
605
+ /** Unitless line-height, clamped to 0.5–4. */
606
+ setLineHeight(lineHeight) {
607
+ if (!Number.isFinite(lineHeight)) return;
608
+ this.commit({ ...this.state.value, lineHeight: clamp(Math.round(lineHeight * 100) / 100, 0.5, 4) });
609
+ }
610
+ /** Tracking in em, clamped to -0.5–1. */
611
+ setLetterSpacing(letterSpacing) {
612
+ if (!Number.isFinite(letterSpacing)) return;
613
+ this.commit({ ...this.state.value, letterSpacing: clamp(Math.round(letterSpacing * 1e3) / 1e3, -0.5, 1) });
614
+ }
615
+ /** Replaces the value (for controlled components). Does not call `onChange` or touch recents. */
616
+ setValue(value) {
617
+ const v = this.state.value;
618
+ const family = value.family ?? v.family;
619
+ const known = findFont(this.state.fonts, family);
620
+ const next = {
621
+ family: known?.family ?? family,
622
+ weight: value.weight !== void 0 ? normalizeFontWeight(value.weight) : v.weight,
623
+ style: value.style ?? v.style,
624
+ size: value.size ?? v.size,
625
+ lineHeight: value.lineHeight ?? v.lineHeight,
626
+ letterSpacing: value.letterSpacing ?? v.letterSpacing,
627
+ ...value.category ?? known?.category ?? (family === v.family ? v.category : void 0) ? { category: value.category ?? known?.category ?? v.category } : {}
628
+ };
629
+ if (JSON.stringify(next) === JSON.stringify(v)) return;
630
+ this.set({ value: next });
631
+ }
632
+ /** Moves the highlight through `results` (wraps around). */
633
+ moveHighlight(delta) {
634
+ const n = this.state.results.length;
635
+ if (!n) return;
636
+ const current = this.state.highlighted;
637
+ const next = current < 0 ? delta > 0 ? 0 : n - 1 : ((current + delta) % n + n) % n;
638
+ this.set({ highlighted: next });
639
+ }
640
+ setHighlight(index) {
641
+ this.set({ highlighted: clamp(Math.trunc(index), -1, this.state.results.length - 1) });
642
+ }
643
+ /** Selects the highlighted result. Returns whether there was one. */
644
+ commitHighlight() {
645
+ const font = this.state.results[this.state.highlighted];
646
+ if (!font) return false;
647
+ this.selectFamily(font.family);
648
+ return true;
649
+ }
650
+ /**
651
+ * Handles combobox keys (ArrowDown/ArrowUp/Home/End/Enter/Escape). Returns whether the key was
652
+ * handled, so the caller can `preventDefault`.
653
+ */
654
+ handleKey(key) {
655
+ switch (key) {
656
+ case "ArrowDown":
657
+ this.moveHighlight(1);
658
+ return true;
659
+ case "ArrowUp":
660
+ this.moveHighlight(-1);
661
+ return true;
662
+ case "Home":
663
+ if (!this.state.results.length) return false;
664
+ this.setHighlight(0);
665
+ return true;
666
+ case "End":
667
+ if (!this.state.results.length) return false;
668
+ this.setHighlight(this.state.results.length - 1);
669
+ return true;
670
+ case "Enter":
671
+ return this.commitHighlight();
672
+ case "Escape":
673
+ if (!this.state.query && this.state.highlighted < 0) return false;
674
+ this.set({ query: "", highlighted: -1 });
675
+ return true;
676
+ default:
677
+ return false;
678
+ }
679
+ }
680
+ /** Loads a family's face through the provider (for previews) and tracks its status. */
681
+ async loadFamily(family, weight, style) {
682
+ const key = family.toLowerCase();
683
+ if (!this.provider?.loadFont) return false;
684
+ if (this.state.loadStatus[key] === "loaded") return true;
685
+ this.set({ loadStatus: { ...this.state.loadStatus, [key]: "loading" } });
686
+ const ok = await this.provider.loadFont(family, weight ?? this.state.value.weight, style ?? this.state.value.style).catch(() => false);
687
+ if (!this.destroyed) this.set({ loadStatus: { ...this.state.loadStatus, [key]: ok ? "loaded" : "failed" } });
688
+ return ok;
689
+ }
690
+ destroy() {
691
+ this.destroyed = true;
692
+ this.listeners.clear();
693
+ this.valueListeners.clear();
694
+ }
695
+ commit(value) {
696
+ if (JSON.stringify(value) === JSON.stringify(this.state.value)) {
697
+ this.set({});
698
+ return;
699
+ }
700
+ this.set({ value });
701
+ const spec = this.value;
702
+ this.onChange?.(spec);
703
+ for (const l of [...this.valueListeners]) l(spec);
704
+ }
705
+ derive(state) {
706
+ const results = filterFonts(state.fonts, { ...state.category !== "all" ? { category: state.category } : {}, query: state.query });
707
+ const recent = this.recentFamilies.map((f) => findFont(state.fonts, f)).filter((f) => !!f);
708
+ return { ...state, results, recent, highlighted: Math.min(state.highlighted, results.length - 1) };
709
+ }
710
+ set(patch) {
711
+ if (this.destroyed) return;
712
+ const next = { ...this.state, ...patch };
713
+ if (patch.error === void 0 && "error" in patch) delete next.error;
714
+ this.state = this.derive(next);
715
+ for (const l of [...this.listeners]) l(this.state);
716
+ }
717
+ };
718
+ function createFontPicker(options = {}) {
719
+ return new FontPickerModel(options);
720
+ }
721
+
722
+ // src/typography/tools.ts
723
+ var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
724
+ var CATEGORY_SCHEMA = { type: "string", enum: FONT_CATEGORIES, description: "Font category: sans-serif, serif, monospace, display or handwriting." };
725
+ var WEIGHT_SCHEMA = {
726
+ oneOf: [
727
+ { type: "integer", enum: FONT_WEIGHTS, description: "Numeric weight 100\u2013900 in steps of 100." },
728
+ { type: "string", enum: ["normal", "bold"], description: "normal (400) or bold (700)." }
729
+ ],
730
+ description: "Font weight: 100\u2013900 in steps of 100, or normal / bold. Default 400."
731
+ };
732
+ var STYLE_SCHEMA = { type: "string", enum: FONT_STYLES, description: "Font style: normal, italic or oblique. Default normal." };
733
+ var RATIO_SCHEMA = {
734
+ oneOf: [
735
+ { type: "string", enum: MODULAR_SCALE_RATIO_NAMES, description: "Named ratio." },
736
+ { type: "number", exclusiveMinimum: 1, maximum: 4, description: "Custom ratio above 1 and at most 4." }
737
+ ],
738
+ description: `Scale ratio: a name (${MODULAR_SCALE_RATIO_NAMES.join(", ")}) or a number above 1 and at most 4. Default major-third (1.25).`
739
+ };
740
+ var rejectUnknownRatio = (ratio) => {
741
+ if (ratio === void 0) return;
742
+ if (typeof ratio === "string" || typeof ratio === "number") resolveScaleRatio(ratio);
743
+ };
744
+ function createTypographyToolProvider(options = {}) {
745
+ const fonts = options.provider ?? createSystemFontProvider();
746
+ const listFonts = {
747
+ name: "list_fonts",
748
+ title: "List fonts",
749
+ description: "Lists the fonts the host offers, optionally filtered by category, source, weight, style or a name query (families starting with the query first). Returns { fonts: [{ family, category, weights, styles, isVariable?, source }], total, truncated }. Use a family from here with resolve_font_stack, load_font or set_font_selection.",
750
+ inputSchema: {
751
+ type: "object",
752
+ properties: {
753
+ category: CATEGORY_SCHEMA,
754
+ source: { type: "string", enum: ["system", "web", "custom"], description: "Only fonts from this source: system (installed), web (loaded over the network) or custom (host-supplied)." },
755
+ weight: WEIGHT_SCHEMA,
756
+ style: STYLE_SCHEMA,
757
+ query: { type: "string", description: "Case-insensitive part of the family name." },
758
+ limit: { type: "integer", minimum: 1, maximum: 500, description: "Most fonts to return (default 50, max 500)." }
759
+ },
760
+ additionalProperties: false
761
+ },
762
+ annotations: { title: "List fonts", ...READ },
763
+ handler: async (args) => {
764
+ const all = await fonts.listFonts();
765
+ const kept = filterFonts(all, { category: args.category, source: args.source, weight: args.weight, style: args.style, query: args.query });
766
+ const limit = args.limit ?? 50;
767
+ return { fonts: kept.slice(0, limit), total: kept.length, truncated: kept.length > limit };
768
+ }
769
+ };
770
+ const resolveFontStack = {
771
+ name: "resolve_font_stack",
772
+ title: "Resolve font stack",
773
+ description: "Builds a resilient CSS font-family value for a family (or an existing stack): the family, then a tested cross-platform fallback stack for its category, ending in a generic family. Returns { stack, families, category, available } where available says whether the font provider lists the first family.",
774
+ inputSchema: {
775
+ type: "object",
776
+ properties: {
777
+ family: { type: "string", description: 'A family name (Inter) or a CSS font-family value ("Inter", Arial).' },
778
+ category: { ...CATEGORY_SCHEMA, description: `${CATEGORY_SCHEMA.description} Default: from the catalog, or guessed from the name.` }
779
+ },
780
+ required: ["family"],
781
+ additionalProperties: false
782
+ },
783
+ annotations: { title: "Resolve font stack", ...READ },
784
+ handler: async (args) => {
785
+ const families = parseFontFamily(String(args.family ?? ""));
786
+ if (!families.length) throw new Error('family must name at least one font, e.g. "Inter".');
787
+ const first = families[0];
788
+ const listed = findFont(await fonts.listFonts(), first);
789
+ const category = args.category ?? listed?.category ?? guessFontCategory(first);
790
+ const stack = resolveFontFallback(args.family, category);
791
+ return { stack, families: parseFontFamily(stack), category, available: !!listed || !!await fonts.hasFont(first) };
792
+ }
793
+ };
794
+ const suggestTypeScale = {
795
+ name: "suggest_type_scale",
796
+ title: "Suggest type scale",
797
+ description: "Computes a modular type scale: size = baseSize \xD7 ratio^step for each step, with a recommended unitless line-height and tracking (em) per size. Returns { ratio, baseSize, steps: [{ name, step, size, lineHeight, letterSpacing }] }. For named heading tokens use generate_type_hierarchy instead.",
798
+ inputSchema: {
799
+ type: "object",
800
+ properties: {
801
+ baseSize: { type: "number", exclusiveMinimum: 0, maximum: 512, description: "Size of step 0 in px (default 16)." },
802
+ ratio: RATIO_SCHEMA,
803
+ steps: { type: "array", items: { type: "integer", minimum: -12, maximum: 12, description: "Step exponent." }, minItems: 1, maxItems: 25, description: "Integer steps to compute, -12 to 12 (default -2 to 5)." }
804
+ },
805
+ additionalProperties: false
806
+ },
807
+ annotations: { title: "Suggest type scale", ...READ },
808
+ handler: (args) => {
809
+ const baseSize = args.baseSize ?? 16;
810
+ const ratio = resolveScaleRatio(args.ratio ?? "major-third");
811
+ return { ratio, baseSize, steps: computeTypeScale(baseSize, ratio, args.steps) };
812
+ }
813
+ };
814
+ const generateHierarchy = {
815
+ name: "generate_type_hierarchy",
816
+ title: "Generate type hierarchy",
817
+ description: "Generates typographic tokens caption, body-sm, body, h4, h3, h2, h1 and display on one modular scale, with line-heights (1.5 for body sizes, tighter for headings) and tracking. Returns { ratio, ratioName?, baseSize, steps: { <name>: { size, lineHeight, letterSpacing, step } }, css } where css declares --<prefix>-<name>-size / -line-height / -letter-spacing custom properties.",
818
+ inputSchema: {
819
+ type: "object",
820
+ properties: {
821
+ baseSize: { type: "number", exclusiveMinimum: 0, maximum: 512, description: "Body size in px (default 16)." },
822
+ ratio: RATIO_SCHEMA,
823
+ prefix: { type: "string", pattern: "^[a-z][a-z0-9-]*$", description: 'Custom property prefix (default "font").' },
824
+ selector: { type: "string", description: 'CSS selector for the custom properties (default ":root").' }
825
+ },
826
+ additionalProperties: false
827
+ },
828
+ annotations: { title: "Generate type hierarchy", ...READ },
829
+ handler: (args) => {
830
+ rejectUnknownRatio(args.ratio);
831
+ const hierarchy = generateTypeHierarchy({ baseSize: args.baseSize, ratio: args.ratio });
832
+ return { ...hierarchy, css: typeHierarchyToCss(hierarchy, { prefix: args.prefix, selector: args.selector }) };
833
+ }
834
+ };
835
+ const fluidSize = {
836
+ name: "compute_fluid_type_size",
837
+ title: "Compute fluid type size",
838
+ description: "Computes a font size that scales linearly with viewport width between two breakpoints, as a CSS clamp() with rem bounds (so browser zoom and font settings still apply). Returns { clampCss, slope, intercept, samples: [{ viewport, size }] } with sizes at both breakpoints and the midpoint.",
839
+ inputSchema: {
840
+ type: "object",
841
+ properties: {
842
+ minSize: { type: "number", exclusiveMinimum: 0, description: "Size in px at minViewport and below." },
843
+ maxSize: { type: "number", exclusiveMinimum: 0, description: "Size in px at maxViewport and above." },
844
+ minViewport: { type: "number", exclusiveMinimum: 0, description: "Viewport width in px where scaling starts (e.g. 375)." },
845
+ maxViewport: { type: "number", exclusiveMinimum: 0, description: "Viewport width in px where scaling stops (e.g. 1440)." },
846
+ rootFontSize: { type: "number", exclusiveMinimum: 0, description: "Root font size in px for rem values (default 16)." }
847
+ },
848
+ required: ["minSize", "maxSize", "minViewport", "maxViewport"],
849
+ additionalProperties: false
850
+ },
851
+ annotations: { title: "Compute fluid type size", ...READ },
852
+ handler: (args) => {
853
+ const fluid = computeFluidSize(args);
854
+ const mid = (args.minViewport + args.maxViewport) / 2;
855
+ return {
856
+ clampCss: fluid.clampCss,
857
+ slope: fluid.slope,
858
+ intercept: fluid.intercept,
859
+ samples: [args.minViewport, mid, args.maxViewport].map((viewport) => ({ viewport, size: fluid.evaluateAt(viewport) }))
860
+ };
861
+ }
862
+ };
863
+ const auditReadability = {
864
+ name: "audit_text_readability",
865
+ title: "Audit text readability",
866
+ description: "Audits body text for line length (45\u201375 characters per line), line spacing (at least 1.5, WCAG 1.4.8), long runs of capitals, tiny sizes and tight tracking, and estimates reading time. Line length comes from the text's own line breaks, or is estimated from width and fontSize when both are given. Returns { lineLength: { characters, words, source, status, recommendation? }, lineSpacing: { ratio, assumed, meetsRecommended, recommendation? }, allCaps, wordCount, estimatedReadingTimeSeconds, issues }.",
867
+ inputSchema: {
868
+ type: "object",
869
+ properties: {
870
+ text: { type: "string", maxLength: 1e5, description: "The text as it is set, with its line breaks." },
871
+ fontSize: { type: "number", exclusiveMinimum: 0, description: "Font size in px." },
872
+ lineHeight: { type: "number", exclusiveMinimum: 0, description: "Unitless line-height (1.5 = 150 %). Default: assumed 1.2 (browser normal)." },
873
+ letterSpacing: { type: "number", description: "Tracking in em (default 0)." },
874
+ width: { type: "number", exclusiveMinimum: 0, description: "Column width in px; with fontSize, estimates characters per line." },
875
+ category: { ...CATEGORY_SCHEMA, description: `${CATEGORY_SCHEMA.description} Used to estimate character width (default sans-serif).` }
876
+ },
877
+ required: ["text"],
878
+ additionalProperties: false
879
+ },
880
+ annotations: { title: "Audit text readability", ...READ },
881
+ handler: (args) => {
882
+ if (args.width !== void 0 && args.fontSize === void 0) throw new Error("width needs fontSize to estimate characters per line; pass both, or neither to measure the text's own lines.");
883
+ return auditTextReadability(args.text, { size: args.fontSize, lineHeight: args.lineHeight, letterSpacing: args.letterSpacing, width: args.width, category: args.category });
884
+ }
885
+ };
886
+ const checkAccessibility = {
887
+ name: "check_typography_accessibility",
888
+ title: "Check typography accessibility",
889
+ description: "Checks text colour against its background with the real font size and weight: decides WCAG 2 large text (24px, or 18.67px bold) itself, reports the ratio, level and required AA / AAA ratios, flags tiny or thin type, and suggests a passing foreground (same hue, nearest OKLCH lightness) when AA fails. Returns { ratio, large, level, passesAA, passesAAA, required: { AA, AAA }, suggestion?, largeTextMinSize, issues }.",
890
+ inputSchema: {
891
+ type: "object",
892
+ properties: {
893
+ foreground: { type: "string", description: "Text colour: hex, rgb(), hsl(), oklch() or a CSS colour name." },
894
+ background: { type: "string", description: "Surface colour behind the text, same formats." },
895
+ fontSize: { type: "number", exclusiveMinimum: 0, description: "Font size in CSS px (12pt = 16px)." },
896
+ fontWeight: WEIGHT_SCHEMA
897
+ },
898
+ required: ["foreground", "background", "fontSize"],
899
+ additionalProperties: false
900
+ },
901
+ annotations: { title: "Check typography accessibility", ...READ },
902
+ handler: (args) => checkTypographyAccessibility({ font: { size: args.fontSize, weight: args.fontWeight }, foreground: args.foreground, background: args.background })
903
+ };
904
+ const segment = {
905
+ name: "segment_text",
906
+ title: "Segment text",
907
+ description: "Splits text into graphemes (letters as people see them, emoji included), words, sentences or lines, with UTF-16 start/end offsets, for staggering live text in animation without converting it to outlines. Returns { segments: [{ text, start, end, index, isWordLike? }], count }.",
908
+ inputSchema: {
909
+ type: "object",
910
+ properties: {
911
+ text: { type: "string", maxLength: 1e5, description: "Text to split." },
912
+ granularity: { type: "string", enum: ["grapheme", "word", "sentence", "line"], description: "grapheme, word, sentence or line (line breaks, plus wrapping with maxCharacters)." },
913
+ locale: { type: "string", description: "BCP 47 locale for word and sentence rules (default: the runtime locale)." },
914
+ wordsOnly: { type: "boolean", description: "For word: drop spaces and punctuation (default false)." },
915
+ maxCharacters: { type: "integer", minimum: 1, description: "For line: wrap lines longer than this many characters at word boundaries." }
916
+ },
917
+ required: ["text", "granularity"],
918
+ additionalProperties: false
919
+ },
920
+ annotations: { title: "Segment text", ...READ },
921
+ handler: (args) => {
922
+ if (args.wordsOnly !== void 0 && args.granularity !== "word") throw new Error('wordsOnly only applies to granularity "word".');
923
+ if (args.maxCharacters !== void 0 && args.granularity !== "line") throw new Error('maxCharacters only applies to granularity "line".');
924
+ const segments = segmentText(args.text, args.granularity, { locale: args.locale, wordsOnly: args.wordsOnly, maxCharacters: args.maxCharacters });
925
+ return { segments, count: segments.length };
926
+ }
927
+ };
928
+ const loadFont = {
929
+ name: "load_font",
930
+ title: "Load font",
931
+ description: "Loads a font face through the host so it can render (web fonts are fetched; installed fonts are ready at once). Call it before rendering or capturing text in a family from list_fonts. Returns { family, weight, style, loaded }; loaded false means the face could not be loaded and a fallback will show.",
932
+ inputSchema: {
933
+ type: "object",
934
+ properties: {
935
+ family: { type: "string", description: "Family name from list_fonts." },
936
+ weight: WEIGHT_SCHEMA,
937
+ style: STYLE_SCHEMA
938
+ },
939
+ required: ["family"],
940
+ additionalProperties: false
941
+ },
942
+ annotations: { title: "Load font", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
943
+ handler: async (args) => {
944
+ const listed = findFont(await fonts.listFonts(), String(args.family));
945
+ if (!listed) throw new Error(`Font "${args.family}" is not offered by this host; call list_fonts to see the families you can load.`);
946
+ const weight = normalizeFontWeight(args.weight);
947
+ const style = args.style ?? "normal";
948
+ const loaded = await fonts.loadFont(listed.family, weight, style);
949
+ return { family: listed.family, weight, style, loaded };
950
+ }
951
+ };
952
+ return {
953
+ id: options.id ?? "typography",
954
+ namespace: options.namespace ?? "typography",
955
+ title: "Typography",
956
+ description: "List and load fonts, build fallback stacks, generate type scales and hierarchies, compute fluid sizes, audit readability and type-aware contrast, and split text for animation.",
957
+ skill: "pixerate-typography",
958
+ tools: [listFonts, resolveFontStack, suggestTypeScale, generateHierarchy, fluidSize, auditReadability, checkAccessibility, segment, ...fonts.loadFont ? [loadFont] : []]
959
+ };
960
+ }
961
+ function createFontPickerToolProvider(model, options = {}) {
962
+ const snapshot = () => ({ value: model.value, css: model.css, availableWeights: model.availableWeights, availableStyles: model.availableStyles });
963
+ const getSelection = {
964
+ name: "get_font_selection",
965
+ title: "Get font selection",
966
+ description: "Returns the font picker's current value and what the chosen family offers: { value: { family, weight, style, size, lineHeight, letterSpacing, category? }, css: { fontFamily, fontWeight, fontStyle, fontSize, lineHeight, letterSpacing }, availableWeights, availableStyles }.",
967
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
968
+ annotations: { title: "Get font selection", ...READ },
969
+ handler: () => snapshot()
970
+ };
971
+ const setSelection = {
972
+ name: "set_font_selection",
973
+ title: "Set font selection",
974
+ description: "Changes the font picker in one step: any of family (from list_fonts), weight, style, size, lineHeight and letterSpacing. A weight the family lacks snaps to the nearest one it has. Everything is validated before anything changes. Returns the new selection, as get_font_selection does.",
975
+ inputSchema: {
976
+ type: "object",
977
+ properties: {
978
+ family: { type: "string", description: "Family name the picker lists (see list_fonts)." },
979
+ weight: WEIGHT_SCHEMA,
980
+ style: STYLE_SCHEMA,
981
+ size: { type: "number", minimum: 1, maximum: 1e3, description: "Font size in px, 1\u20131000." },
982
+ lineHeight: { type: "number", minimum: 0.5, maximum: 4, description: "Unitless line-height, 0.5\u20134." },
983
+ letterSpacing: { type: "number", minimum: -0.5, maximum: 1, description: "Tracking in em, -0.5\u20131." }
984
+ },
985
+ additionalProperties: false
986
+ },
987
+ annotations: { title: "Set font selection", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
988
+ handler: (args) => {
989
+ const reason = options.readOnly?.();
990
+ if (reason) throw new Error(`The font picker is read-only: ${reason}`);
991
+ if (!Object.keys(args).length) throw new Error("Pass at least one of family, weight, style, size, lineHeight or letterSpacing.");
992
+ const { fonts } = model.getState();
993
+ if (args.family !== void 0 && fonts.length && !findFont(fonts, args.family)) {
994
+ throw new Error(`Font "${args.family}" is not in the picker; call list_fonts and pass one of its families.`);
995
+ }
996
+ if (args.style !== void 0) {
997
+ const font = args.family !== void 0 ? findFont(fonts, args.family) : model.selectedFont;
998
+ if (font && font.styles.length && !font.styles.includes(args.style)) throw new Error(`"${font.family}" has no ${args.style} style; it offers ${font.styles.join(", ")}.`);
999
+ }
1000
+ if (args.family !== void 0) model.selectFamily(args.family);
1001
+ if (args.weight !== void 0) model.setWeight(args.weight);
1002
+ if (args.style !== void 0) model.setStyle(args.style);
1003
+ if (args.size !== void 0) model.setSize(args.size);
1004
+ if (args.lineHeight !== void 0) model.setLineHeight(args.lineHeight);
1005
+ if (args.letterSpacing !== void 0) model.setLetterSpacing(args.letterSpacing);
1006
+ return snapshot();
1007
+ }
1008
+ };
1009
+ const listPickerFonts = {
1010
+ name: "list_picker_fonts",
1011
+ title: "List picker fonts",
1012
+ description: "Lists the families this font picker offers, optionally filtered by category and a name query, newest recent families first in recent. Returns { fonts: [{ family, category, weights, styles, isVariable?, source }], recent: [family], total, truncated }.",
1013
+ inputSchema: {
1014
+ type: "object",
1015
+ properties: {
1016
+ category: CATEGORY_SCHEMA,
1017
+ query: { type: "string", description: "Case-insensitive part of the family name." },
1018
+ limit: { type: "integer", minimum: 1, maximum: 500, description: "Most fonts to return (default 50, max 500)." }
1019
+ },
1020
+ additionalProperties: false
1021
+ },
1022
+ annotations: { title: "List picker fonts", ...READ },
1023
+ handler: (args) => {
1024
+ const state = model.getState();
1025
+ const kept = filterFonts(state.fonts, { category: args.category, query: args.query });
1026
+ const limit = args.limit ?? 50;
1027
+ return { fonts: kept.slice(0, limit), recent: state.recent.map((f) => f.family), total: kept.length, truncated: kept.length > limit };
1028
+ }
1029
+ };
1030
+ return {
1031
+ id: options.id ?? "font-picker",
1032
+ namespace: options.namespace ?? "fontpicker",
1033
+ title: "Font picker",
1034
+ description: "Read and change the font picker on screen: family, weight, style, size, line height and letter spacing.",
1035
+ skill: "pixerate-typography",
1036
+ tools: [getSelection, listPickerFonts, setSelection]
1037
+ };
1038
+ }
1039
+ export {
1040
+ ASSUMED_NORMAL_LINE_HEIGHT,
1041
+ CATEGORY_FALLBACK_STACK,
1042
+ DEFAULT_PREVIEW_TEXT,
1043
+ FONT_CATEGORIES,
1044
+ FONT_CATEGORY_LABELS,
1045
+ FONT_PLATFORMS,
1046
+ FONT_STACKS,
1047
+ FONT_STACK_NAMES,
1048
+ FONT_STYLES,
1049
+ FONT_WEIGHTS,
1050
+ FontPickerModel,
1051
+ GENERIC_FONT_FAMILIES,
1052
+ LARGE_BOLD_TEXT_PX,
1053
+ LARGE_TEXT_PX,
1054
+ MODULAR_SCALE_RATIOS,
1055
+ MODULAR_SCALE_RATIO_NAMES,
1056
+ OPTIMAL_MEASURE,
1057
+ READING_WORDS_PER_MINUTE,
1058
+ RECOMMENDED_LINE_HEIGHT,
1059
+ SYSTEM_FONT_CATALOG,
1060
+ TYPE_HIERARCHY_STEPS,
1061
+ auditTextReadability,
1062
+ checkTypographyAccessibility,
1063
+ combineFontProviders,
1064
+ computeFluidSize,
1065
+ computeTypeScale,
1066
+ countGraphemes,
1067
+ createCatalogFontProvider,
1068
+ createFontPicker,
1069
+ createFontPickerToolProvider,
1070
+ createSystemFontProvider,
1071
+ createTypographyToolProvider,
1072
+ createWebFontProvider,
1073
+ ensureFontsLoaded,
1074
+ estimateCharactersPerLine,
1075
+ filterFonts,
1076
+ findFont,
1077
+ fontFaceCss,
1078
+ fontHasWeight,
1079
+ formatFontFamily,
1080
+ generateTypeHierarchy,
1081
+ getScaleRatio,
1082
+ graphemes,
1083
+ guessFontCategory,
1084
+ isFontWeight,
1085
+ isLargeText,
1086
+ nearestFontWeight,
1087
+ normalizeFontWeight,
1088
+ offeredWeights,
1089
+ parseFontFamily,
1090
+ quoteFontFamily,
1091
+ recommendedLetterSpacing,
1092
+ recommendedLineHeight,
1093
+ resolveFontFallback,
1094
+ resolveScaleRatio,
1095
+ segmentText,
1096
+ typeHierarchyToCss,
1097
+ typeScaleStep,
1098
+ webFontSummary
1099
+ };