figmatk 0.2.6 → 0.3.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.
@@ -13,7 +13,7 @@
13
13
  {
14
14
  "name": "figmatk",
15
15
  "description": "Swiss Army Knife for Figma Files (.deck)",
16
- "version": "0.2.6",
16
+ "version": "0.3.1",
17
17
  "author": {
18
18
  "name": "FigmaTK Contributors"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Create and edit Figma Slides .deck files programmatically — no Figma API required",
5
5
  "author": {
6
6
  "name": "FigmaTK Contributors"
package/README.md CHANGED
@@ -67,7 +67,23 @@ Or add manually in Claude Desktop → Settings → Developer → Edit Config:
67
67
  }
68
68
  ```
69
69
 
70
- Available MCP tools: `figmatk_create_deck`, `figmatk_inspect`, `figmatk_list_text`, `figmatk_list_overrides`, `figmatk_update_text`, `figmatk_insert_image`, `figmatk_clone_slide`, `figmatk_remove_slide`, `figmatk_roundtrip`.
70
+ Available MCP tools: `figmatk_create_deck`, `figmatk_create_template_draft`, `figmatk_annotate_template_layout`, `figmatk_publish_template_draft`, `figmatk_list_template_layouts`, `figmatk_create_from_template`, `figmatk_inspect`, `figmatk_list_text`, `figmatk_list_overrides`, `figmatk_update_text`, `figmatk_insert_image`, `figmatk_clone_slide`, `figmatk_remove_slide`, `figmatk_roundtrip`.
71
+
72
+ ## Template Workflows
73
+
74
+ FigmaTK supports two related template states:
75
+
76
+ - Draft templates: `SLIDE_ROW -> SLIDE -> ...`
77
+ - Published templates: `SLIDE_ROW -> MODULE -> SLIDE -> ...`
78
+
79
+ Use explicit naming conventions when authoring reusable templates:
80
+
81
+ - Layouts: `layout:<name>`
82
+ - Text slots: `slot:text:<name>`
83
+ - Image slots: `slot:image:<name>`
84
+ - Decorative fixed imagery: `fixed:image:<name>`
85
+
86
+ `figmatk_list_template_layouts` understands those conventions and only falls back to heuristic image placeholders when a layout has not been explicitly annotated yet.
71
87
 
72
88
  ## Programmatic API
73
89
 
@@ -84,6 +100,7 @@ await deck.save('output.deck');
84
100
  |------|---|
85
101
  | High-level API | [docs/figmatk-api-spec.md](docs/figmatk-api-spec.md) |
86
102
  | Low-level FigDeck API | [docs/library.md](docs/library.md) |
103
+ | Template workflows | [docs/template-workflows.md](docs/template-workflows.md) |
87
104
  | File format internals | [docs/format/](docs/format/) |
88
105
 
89
106
  ## License
package/lib/fig-deck.mjs CHANGED
@@ -14,9 +14,10 @@ import { decompress } from 'fzstd';
14
14
  import { inflateRaw, deflateRaw } from 'pako';
15
15
  import { ZstdCodec } from 'zstd-codec';
16
16
  import archiver from 'archiver';
17
- import { readFileSync, createWriteStream, existsSync, mkdirSync, cpSync } from 'fs';
17
+ import { readFileSync, createWriteStream, existsSync, mkdtempSync } from 'fs';
18
18
  import { execSync } from 'child_process';
19
19
  import { join, resolve } from 'path';
20
+ import { tmpdir } from 'os';
20
21
  import { nid } from './node-helpers.mjs';
21
22
 
22
23
  export class FigDeck {
@@ -42,9 +43,8 @@ export class FigDeck {
42
43
  const absPath = resolve(deckPath);
43
44
 
44
45
  // Extract to temp dir
45
- const tmp = `/tmp/figmatk_${Date.now()}`;
46
- execSync(`rm -rf ${tmp} && mkdir -p ${tmp}`);
47
- execSync(`unzip -o "${absPath}" -d ${tmp}`, { stdio: 'pipe' });
46
+ const tmp = mkdtempSync(join(tmpdir(), 'figmatk_'));
47
+ execSync(`unzip -o "${absPath}" -d "${tmp}"`, { stdio: 'pipe' });
48
48
  deck._tempDir = tmp;
49
49
 
50
50
  // Read canvas.fig
@@ -0,0 +1,643 @@
1
+ /**
2
+ * template-deck — Inspect, author, wrap, and instantiate Figma Slides templates.
3
+ *
4
+ * Template workflows now cover two structural states:
5
+ * - Draft templates: SLIDE_ROW -> SLIDE -> ...
6
+ * - Published templates: SLIDE_ROW -> MODULE -> SLIDE -> ...
7
+ */
8
+ import { createHash } from 'crypto';
9
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync } from 'fs';
10
+ import { join, resolve } from 'path';
11
+ import { Deck } from './api.mjs';
12
+ import { deepClone } from './deep-clone.mjs';
13
+ import { FigDeck } from './fig-deck.mjs';
14
+ import { hexToHash } from './image-helpers.mjs';
15
+ import { getImageDimensions, generateThumbnail } from './image-utils.mjs';
16
+ import { nid, positionChar } from './node-helpers.mjs';
17
+
18
+ export const LAYOUT_PREFIX = 'layout:';
19
+ export const TEXT_SLOT_PREFIX = 'slot:text:';
20
+ export const IMAGE_SLOT_PREFIX = 'slot:image:';
21
+ export const FIXED_IMAGE_PREFIX = 'fixed:image:';
22
+
23
+ const INTERNAL_CANVAS_NAME = 'Internal Only Canvas';
24
+ const MODULE_VERSION = '1:37';
25
+ const DEFAULT_ROW_GAP = 2160;
26
+
27
+ /**
28
+ * Inspect a template deck and return available layouts plus explicit slot metadata.
29
+ */
30
+ export async function listTemplateLayouts(templatePath, opts = {}) {
31
+ const deck = await FigDeck.fromDeckFile(templatePath);
32
+ return describeTemplateLayouts(deck, opts);
33
+ }
34
+
35
+ /**
36
+ * Create a new draft template deck from scratch.
37
+ */
38
+ export async function createDraftTemplate(outputPath, opts = {}) {
39
+ const title = opts.title ?? 'Untitled';
40
+ const layoutNames = Array.isArray(opts.layouts) && opts.layouts.length
41
+ ? opts.layouts
42
+ : ['cover'];
43
+
44
+ const deck = await Deck.create({ name: title });
45
+ for (const name of layoutNames) {
46
+ deck.addBlankSlide({ name: normalizeLayoutName(name) });
47
+ }
48
+
49
+ await deck.save(outputPath);
50
+ return statSync(resolve(outputPath)).size;
51
+ }
52
+
53
+ /**
54
+ * Add or update explicit layout/slot metadata on an existing draft or published template.
55
+ */
56
+ export async function annotateTemplateLayout(path, outputPath, opts = {}) {
57
+ const deck = await FigDeck.fromDeckFile(path);
58
+ const slide = deck.getNode(opts.slideId);
59
+ if (!slide || slide.type !== 'SLIDE') {
60
+ throw new Error(`Slide not found: ${opts.slideId}`);
61
+ }
62
+
63
+ const module = getParentModule(deck, slide);
64
+ if (opts.layoutName) {
65
+ const layoutName = normalizeLayoutName(opts.layoutName);
66
+ slide.name = layoutName;
67
+ if (module) module.name = layoutName;
68
+ }
69
+
70
+ renameNodes(deck, opts.textSlots, TEXT_SLOT_PREFIX);
71
+ renameNodes(deck, opts.imageSlots, IMAGE_SLOT_PREFIX);
72
+ renameNodes(deck, opts.fixedImages, FIXED_IMAGE_PREFIX);
73
+
74
+ return deck.saveDeck(outputPath);
75
+ }
76
+
77
+ /**
78
+ * Convert draft slides into publish-like module-backed layouts.
79
+ */
80
+ export async function publishTemplateDraft(path, outputPath, opts = {}) {
81
+ const deck = await FigDeck.fromDeckFile(path);
82
+ const targetIds = new Set(opts.slideIds ?? []);
83
+ const layouts = describeTemplateLayouts(deck);
84
+ const draftLayouts = layouts.filter(layout => layout.state === 'draft');
85
+ const targets = targetIds.size
86
+ ? draftLayouts.filter(layout => targetIds.has(layout.slideId))
87
+ : draftLayouts;
88
+
89
+ if (!targets.length) {
90
+ throw new Error('No draft template slides found to wrap');
91
+ }
92
+
93
+ let nextId = deck.maxLocalID() + 1;
94
+ for (const layout of targets) {
95
+ const slide = deck.getNode(layout.slideId);
96
+ if (!slide) continue;
97
+
98
+ const row = deck.getNode(layout.rowId);
99
+ if (!row) {
100
+ throw new Error(`Slide row not found for ${layout.slideId}`);
101
+ }
102
+
103
+ const moduleGuid = { sessionID: slide.guid.sessionID, localID: nextId++ };
104
+ const module = createModuleWrapper(slide, moduleGuid);
105
+ module.parentIndex = deepClone(slide.parentIndex);
106
+
107
+ slide.parentIndex = { guid: deepClone(moduleGuid), position: '!' };
108
+
109
+ deck.message.nodeChanges.push(module);
110
+ }
111
+
112
+ deck.rebuildMaps();
113
+ return deck.saveDeck(outputPath);
114
+ }
115
+
116
+ /**
117
+ * Create a new deck from a template by cherry-picking and populating layouts.
118
+ *
119
+ * @param {string} templatePath
120
+ * @param {string} outputPath
121
+ * @param {Array<{slideId: string, text?: Record<string, string>, images?: Record<string, string>}>} slideDefs
122
+ */
123
+ export async function createFromTemplate(templatePath, outputPath, slideDefs) {
124
+ const deck = await FigDeck.fromDeckFile(templatePath);
125
+ const layouts = describeTemplateLayouts(deck);
126
+ const layoutBySlideId = new Map(layouts.map(layout => [layout.slideId, layout]));
127
+ const mainRows = getMainSlideRows(deck);
128
+ const targetRow = mainRows[0];
129
+
130
+ if (!targetRow) {
131
+ throw new Error('No main-canvas SLIDE_ROW found in template');
132
+ }
133
+
134
+ let nextId = deck.maxLocalID() + 1;
135
+ const sessionId = 200;
136
+
137
+ for (let defIdx = 0; defIdx < slideDefs.length; defIdx++) {
138
+ const def = slideDefs[defIdx];
139
+ const sourceLayout = layoutBySlideId.get(def.slideId);
140
+ if (!sourceLayout) throw new Error(`Layout not found: ${def.slideId}`);
141
+
142
+ const rootId = sourceLayout.moduleId ?? sourceLayout.slideId;
143
+ const subtreeNodes = [];
144
+ deck.walkTree(rootId, node => {
145
+ if (node.phase !== 'REMOVED') subtreeNodes.push(node);
146
+ });
147
+
148
+ const idMap = new Map();
149
+ for (const node of subtreeNodes) {
150
+ idMap.set(nid(node), { sessionID: sessionId, localID: nextId++ });
151
+ }
152
+
153
+ const reverseIdMap = new Map();
154
+ for (const [oldId, guid] of idMap.entries()) {
155
+ reverseIdMap.set(`${guid.sessionID}:${guid.localID}`, oldId);
156
+ }
157
+
158
+ const clonedNodes = subtreeNodes.map(node => {
159
+ const clone = deepClone(node);
160
+ const oldId = nid(node);
161
+ const newGuid = idMap.get(oldId);
162
+ if (newGuid) clone.guid = newGuid;
163
+
164
+ if (oldId === rootId) {
165
+ clone.parentIndex = {
166
+ guid: deepClone(targetRow.guid),
167
+ position: positionChar(defIdx),
168
+ };
169
+ if (clone.transform) {
170
+ clone.transform.m02 = defIdx * DEFAULT_ROW_GAP;
171
+ }
172
+ } else if (clone.parentIndex?.guid) {
173
+ const parentId = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
174
+ const remappedParent = idMap.get(parentId);
175
+ if (remappedParent) {
176
+ clone.parentIndex = { ...clone.parentIndex, guid: remappedParent };
177
+ }
178
+ }
179
+
180
+ clone.phase = 'CREATED';
181
+ delete clone.slideThumbnailHash;
182
+ delete clone.editInfo;
183
+ delete clone.prototypeInteractions;
184
+
185
+ return clone;
186
+ });
187
+
188
+ for (const clone of clonedNodes) {
189
+ const originalId = reverseIdMap.get(nid(clone));
190
+ const textValue = pickMappedValue(def.text, candidateTextKeys(sourceLayout, clone, originalId));
191
+ if (textValue !== undefined) {
192
+ applyTextValue(clone, textValue);
193
+ }
194
+
195
+ const imagePath = pickMappedValue(def.images, candidateImageKeys(sourceLayout, clone, originalId));
196
+ if (imagePath !== undefined) {
197
+ await applyImageValue(deck, clone, imagePath);
198
+ }
199
+ }
200
+
201
+ deck.message.nodeChanges.push(...clonedNodes);
202
+ }
203
+
204
+ deck.rebuildMaps();
205
+
206
+ const pruneIds = new Set();
207
+ for (const layout of layouts) {
208
+ collectSubtree(deck, layout.moduleId ?? layout.slideId, pruneIds);
209
+ }
210
+
211
+ const targetRowId = nid(targetRow);
212
+ const extraRowIds = new Set(mainRows.slice(1).map(row => nid(row)));
213
+
214
+ deck.message.nodeChanges = deck.message.nodeChanges.filter(node => {
215
+ const id = nid(node);
216
+ if (!id) return true;
217
+ if (pruneIds.has(id)) return false;
218
+ if (id !== targetRowId && extraRowIds.has(id)) return false;
219
+ return true;
220
+ });
221
+
222
+ deck.rebuildMaps();
223
+ return deck.saveDeck(outputPath);
224
+ }
225
+
226
+ function describeTemplateLayouts(deck, opts = {}) {
227
+ const includeInternal = Boolean(opts.includeInternal);
228
+ const rows = includeInternal
229
+ ? deck.message.nodeChanges.filter(node => node.type === 'SLIDE_ROW' && node.phase !== 'REMOVED')
230
+ : getMainSlideRows(deck);
231
+
232
+ const layouts = [];
233
+ for (const row of rows) {
234
+ for (const layout of getRowLayouts(deck, row)) {
235
+ layouts.push(describeLayout(deck, layout, row));
236
+ }
237
+ }
238
+ return layouts;
239
+ }
240
+
241
+ function getMainSlideRows(deck) {
242
+ return deck.message.nodeChanges.filter(node => {
243
+ if (node.type !== 'SLIDE_ROW' || node.phase === 'REMOVED') return false;
244
+ const canvas = getAncestorCanvas(deck, node);
245
+ return !isInternalCanvas(canvas);
246
+ });
247
+ }
248
+
249
+ function getRowLayouts(deck, row) {
250
+ const layouts = [];
251
+ for (const child of deck.getChildren(nid(row))) {
252
+ if (child.phase === 'REMOVED') continue;
253
+ if (child.type === 'MODULE') {
254
+ for (const maybeSlide of deck.getChildren(nid(child))) {
255
+ if (maybeSlide.phase === 'REMOVED' || maybeSlide.type !== 'SLIDE') continue;
256
+ layouts.push({ slide: maybeSlide, module: child });
257
+ }
258
+ continue;
259
+ }
260
+ if (child.type === 'SLIDE') {
261
+ layouts.push({ slide: child, module: null });
262
+ }
263
+ }
264
+ return layouts;
265
+ }
266
+
267
+ function describeLayout(deck, layout, row) {
268
+ const rootId = layout.module ? nid(layout.module) : nid(layout.slide);
269
+ const slotDiscovery = discoverSlots(deck, rootId);
270
+ const nameSource = layout.module?.name || layout.slide.name || layout.slide.name || 'Untitled';
271
+ const canonicalName = stripLayoutPrefix(nameSource);
272
+
273
+ return {
274
+ slideId: nid(layout.slide),
275
+ moduleId: layout.module ? nid(layout.module) : null,
276
+ rowId: nid(row),
277
+ rowName: row.name || 'Slide row',
278
+ name: canonicalName,
279
+ rawName: nameSource,
280
+ state: layout.module ? 'published' : 'draft',
281
+ hasExplicitSlotMetadata: slotDiscovery.hasExplicitSlotMetadata,
282
+ slots: [...slotDiscovery.textSlots, ...slotDiscovery.imageSlots],
283
+ textFields: slotDiscovery.textSlots.map(slot => ({
284
+ nodeId: slot.nodeId,
285
+ name: slot.name,
286
+ preview: slot.preview,
287
+ source: slot.source,
288
+ })),
289
+ imagePlaceholders: slotDiscovery.imageSlots.map(slot => ({
290
+ nodeId: slot.nodeId,
291
+ name: slot.name,
292
+ type: slot.nodeType,
293
+ width: slot.width,
294
+ height: slot.height,
295
+ hasCurrentImage: slot.hasCurrentImage,
296
+ source: slot.source,
297
+ })),
298
+ };
299
+ }
300
+
301
+ function discoverSlots(deck, rootId) {
302
+ const explicitTextSlots = [];
303
+ const explicitImageSlots = [];
304
+ const fallbackTextSlots = [];
305
+ const fallbackImageSlots = [];
306
+
307
+ deck.walkTree(rootId, node => {
308
+ const textSlotName = parsePrefixedName(node.name, TEXT_SLOT_PREFIX);
309
+ if (textSlotName) {
310
+ const slot = describeTextSlot(node, textSlotName, 'explicit');
311
+ if (slot) explicitTextSlots.push(slot);
312
+ return;
313
+ }
314
+
315
+ const imageSlotName = parsePrefixedName(node.name, IMAGE_SLOT_PREFIX);
316
+ if (imageSlotName) {
317
+ const slot = describeImageSlot(node, imageSlotName, 'explicit');
318
+ if (slot) explicitImageSlots.push(slot);
319
+ return;
320
+ }
321
+
322
+ if (parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) {
323
+ return;
324
+ }
325
+
326
+ const fallbackText = describeFallbackTextSlot(node);
327
+ if (fallbackText) fallbackTextSlots.push(fallbackText);
328
+
329
+ const fallbackImage = describeFallbackImageSlot(deck, node);
330
+ if (fallbackImage) fallbackImageSlots.push(fallbackImage);
331
+ });
332
+
333
+ const hasExplicitSlotMetadata = explicitTextSlots.length > 0 || explicitImageSlots.length > 0;
334
+
335
+ return {
336
+ hasExplicitSlotMetadata,
337
+ textSlots: explicitTextSlots.length ? explicitTextSlots : fallbackTextSlots,
338
+ imageSlots: hasExplicitSlotMetadata ? explicitImageSlots : fallbackImageSlots,
339
+ };
340
+ }
341
+
342
+ function describeFallbackTextSlot(node) {
343
+ if (node.type === 'TEXT' && node.name) {
344
+ return describeTextSlot(node, node.name, 'heuristic');
345
+ }
346
+
347
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides?.length) {
348
+ return {
349
+ type: 'text',
350
+ nodeId: nid(node),
351
+ name: `#${nid(node)}`,
352
+ preview: firstShapeText(node),
353
+ source: 'heuristic',
354
+ nodeType: node.type,
355
+ };
356
+ }
357
+
358
+ return null;
359
+ }
360
+
361
+ function describeTextSlot(node, name, source) {
362
+ if (node.type === 'TEXT') {
363
+ return {
364
+ type: 'text',
365
+ nodeId: nid(node),
366
+ name,
367
+ preview: (node.textData?.characters ?? '').slice(0, 80),
368
+ source,
369
+ nodeType: node.type,
370
+ };
371
+ }
372
+
373
+ if (node.type === 'SHAPE_WITH_TEXT') {
374
+ return {
375
+ type: 'text',
376
+ nodeId: nid(node),
377
+ name,
378
+ preview: firstShapeText(node),
379
+ source,
380
+ nodeType: node.type,
381
+ };
382
+ }
383
+
384
+ return null;
385
+ }
386
+
387
+ function describeFallbackImageSlot(deck, node) {
388
+ if (node.name && parsePrefixedName(node.name, FIXED_IMAGE_PREFIX)) return null;
389
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE');
390
+ const isLargeEmptyFrame = (node.type === 'FRAME' || node.type === 'ROUNDED_RECTANGLE')
391
+ && (node.size?.x ?? 0) > 100
392
+ && (node.size?.y ?? 0) > 100
393
+ && deck.getChildren(nid(node)).filter(child => child.phase !== 'REMOVED').length === 0;
394
+
395
+ if (!hasImageFill && !isLargeEmptyFrame) return null;
396
+
397
+ return describeImageSlot(node, `#${nid(node)}`, 'heuristic');
398
+ }
399
+
400
+ function describeImageSlot(node, name, source) {
401
+ const hasImageFill = node.fillPaints?.some(fill => fill.type === 'IMAGE') ?? false;
402
+ return {
403
+ type: 'image',
404
+ nodeId: nid(node),
405
+ name,
406
+ source,
407
+ nodeType: node.type,
408
+ width: Math.round(node.size?.x ?? 0),
409
+ height: Math.round(node.size?.y ?? 0),
410
+ hasCurrentImage: hasImageFill,
411
+ };
412
+ }
413
+
414
+ function candidateTextKeys(layout, node, originalId) {
415
+ const keys = [];
416
+ const field = layout.textFields.find(entry => entry.nodeId === originalId);
417
+ if (field?.name) keys.push(field.name);
418
+ if (originalId) {
419
+ keys.push(originalId);
420
+ keys.push(`#${originalId}`);
421
+ }
422
+ if (node.name) keys.push(node.name);
423
+ return dedupe(keys);
424
+ }
425
+
426
+ function candidateImageKeys(layout, node, originalId) {
427
+ const keys = [];
428
+ const field = layout.imagePlaceholders.find(entry => entry.nodeId === originalId);
429
+ if (field?.name) keys.push(field.name);
430
+ if (originalId) {
431
+ keys.push(originalId);
432
+ keys.push(`#${originalId}`);
433
+ }
434
+ if (node.name) keys.push(node.name);
435
+ return dedupe(keys);
436
+ }
437
+
438
+ function pickMappedValue(map, keys) {
439
+ if (!map) return undefined;
440
+ for (const key of keys) {
441
+ if (key in map) return map[key];
442
+ }
443
+ return undefined;
444
+ }
445
+
446
+ function applyTextValue(node, text) {
447
+ const chars = text === '' || text == null ? ' ' : text;
448
+
449
+ if (node.type === 'TEXT') {
450
+ if (!node.textData) node.textData = {};
451
+ node.textData.characters = chars;
452
+ node.textData.lines = chars.split('\n').map(() => ({
453
+ lineType: 'PLAIN',
454
+ styleId: 0,
455
+ indentationLevel: 0,
456
+ sourceDirectionality: 'AUTO',
457
+ listStartOffset: 0,
458
+ isFirstLineOfList: false,
459
+ }));
460
+ delete node.derivedTextData;
461
+ return;
462
+ }
463
+
464
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
465
+ for (const override of node.nodeGenerationData.overrides) {
466
+ if (!override.textData) continue;
467
+ override.textData.characters = chars;
468
+ override.textData.lines = chars.split('\n').map(() => ({
469
+ lineType: 'PLAIN',
470
+ styleId: 0,
471
+ indentationLevel: 0,
472
+ sourceDirectionality: 'AUTO',
473
+ listStartOffset: 0,
474
+ isFirstLineOfList: false,
475
+ }));
476
+ }
477
+ delete node.derivedImmutableFrameData;
478
+ }
479
+ }
480
+
481
+ async function applyImageValue(deck, node, imagePath) {
482
+ const absPath = resolve(imagePath);
483
+ const imgBuf = readFileSync(absPath);
484
+ const imgHash = sha1Hex(imgBuf);
485
+ const { width, height } = await getImageDimensions(imgBuf);
486
+
487
+ const tmpThumb = `/tmp/figmatk_thumb_${Date.now()}_${Math.random().toString(36).slice(2)}.png`;
488
+ await generateThumbnail(imgBuf, tmpThumb);
489
+ const thumbHash = sha1Hex(readFileSync(tmpThumb));
490
+
491
+ copyToImagesDir(deck, imgHash, absPath);
492
+ copyToImagesDir(deck, thumbHash, tmpThumb);
493
+
494
+ const fill = {
495
+ type: 'IMAGE',
496
+ opacity: 1,
497
+ visible: true,
498
+ blendMode: 'NORMAL',
499
+ transform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 },
500
+ image: { hash: hexToHash(imgHash), name: imgHash },
501
+ imageThumbnail: { hash: hexToHash(thumbHash), name: thumbHash },
502
+ animationFrame: 0,
503
+ imageScaleMode: existingImageScaleMode(node) ?? 'FILL',
504
+ imageShouldColorManage: false,
505
+ rotation: 0,
506
+ scale: 0.5,
507
+ originalImageWidth: width,
508
+ originalImageHeight: height,
509
+ thumbHash: new Uint8Array(0),
510
+ altText: '',
511
+ };
512
+
513
+ if (node.fillPaints?.length) {
514
+ const idx = node.fillPaints.findIndex(paint => paint.type === 'IMAGE');
515
+ if (idx >= 0) {
516
+ node.fillPaints.splice(idx, 1, fill);
517
+ } else {
518
+ node.fillPaints = [fill];
519
+ }
520
+ } else {
521
+ node.fillPaints = [fill];
522
+ }
523
+
524
+ delete node.derivedImmutableFrameData;
525
+ }
526
+
527
+ function collectSubtree(deck, rootId, seen) {
528
+ if (!rootId || seen.has(rootId)) return;
529
+ seen.add(rootId);
530
+ for (const child of deck.getChildren(rootId)) {
531
+ collectSubtree(deck, nid(child), seen);
532
+ }
533
+ }
534
+
535
+ function renameNodes(deck, map, prefix) {
536
+ if (!map) return;
537
+ for (const [nodeId, name] of Object.entries(map)) {
538
+ const node = deck.getNode(nodeId);
539
+ if (!node) throw new Error(`Node not found: ${nodeId}`);
540
+ node.name = `${prefix}${stripPrefix(name)}`;
541
+ }
542
+ }
543
+
544
+ function createModuleWrapper(slide, moduleGuid) {
545
+ return {
546
+ guid: deepClone(moduleGuid),
547
+ phase: 'CREATED',
548
+ type: 'MODULE',
549
+ name: slide.name ?? 'layout',
550
+ isPublishable: true,
551
+ version: MODULE_VERSION,
552
+ userFacingVersion: MODULE_VERSION,
553
+ visible: true,
554
+ opacity: 1,
555
+ size: deepClone(slide.size ?? { x: 1920, y: 1080 }),
556
+ transform: { m00: 1, m01: 0, m02: slide.transform?.m02 ?? 0, m10: 0, m11: 1, m12: slide.transform?.m12 ?? 0 },
557
+ strokeWeight: 1,
558
+ strokeAlign: 'INSIDE',
559
+ strokeJoin: 'MITER',
560
+ fillPaints: deepClone(slide.fillPaints ?? [{
561
+ type: 'SOLID',
562
+ color: { r: 1, g: 1, b: 1, a: 1 },
563
+ opacity: 1,
564
+ visible: true,
565
+ blendMode: 'NORMAL',
566
+ }]),
567
+ fillGeometry: [{
568
+ windingRule: 'NONZERO',
569
+ commandsBlob: 13,
570
+ styleID: 0,
571
+ }],
572
+ frameMaskDisabled: false,
573
+ };
574
+ }
575
+
576
+ function getParentModule(deck, slide) {
577
+ if (!slide?.parentIndex?.guid) return null;
578
+ const parent = deck.getNode(guidId(slide.parentIndex.guid));
579
+ return parent?.type === 'MODULE' ? parent : null;
580
+ }
581
+
582
+ function getAncestorCanvas(deck, node) {
583
+ let current = node;
584
+ while (current?.parentIndex?.guid) {
585
+ const parent = deck.getNode(guidId(current.parentIndex.guid));
586
+ if (!parent) return null;
587
+ if (parent.type === 'CANVAS') return parent;
588
+ current = parent;
589
+ }
590
+ return null;
591
+ }
592
+
593
+ function isInternalCanvas(canvas) {
594
+ return canvas?.name === INTERNAL_CANVAS_NAME;
595
+ }
596
+
597
+ function parsePrefixedName(value, prefix) {
598
+ if (!value || !value.startsWith(prefix)) return null;
599
+ return stripPrefix(value.slice(prefix.length));
600
+ }
601
+
602
+ function normalizeLayoutName(value) {
603
+ const stripped = stripLayoutPrefix(value || 'layout');
604
+ return `${LAYOUT_PREFIX}${stripped}`;
605
+ }
606
+
607
+ function stripLayoutPrefix(value) {
608
+ return parsePrefixedName(value, LAYOUT_PREFIX) ?? stripPrefix(value);
609
+ }
610
+
611
+ function stripPrefix(value) {
612
+ return String(value ?? '').trim().replace(/^(layout:|slot:text:|slot:image:|fixed:image:)/, '');
613
+ }
614
+
615
+ function firstShapeText(node) {
616
+ const text = node.nodeGenerationData?.overrides?.find(override => override.textData?.characters)?.textData?.characters ?? '';
617
+ return text.trim().slice(0, 80);
618
+ }
619
+
620
+ function existingImageScaleMode(node) {
621
+ return node.fillPaints?.find(fill => fill.type === 'IMAGE')?.imageScaleMode;
622
+ }
623
+
624
+ function dedupe(values) {
625
+ return [...new Set(values.filter(Boolean))];
626
+ }
627
+
628
+ function guidId(guid) {
629
+ return guid ? `${guid.sessionID}:${guid.localID}` : null;
630
+ }
631
+
632
+ function sha1Hex(buf) {
633
+ return createHash('sha1').update(buf).digest('hex');
634
+ }
635
+
636
+ function copyToImagesDir(deck, hash, srcPath) {
637
+ if (!deck.imagesDir) {
638
+ deck.imagesDir = `/tmp/figmatk_images_${Date.now()}`;
639
+ mkdirSync(deck.imagesDir, { recursive: true });
640
+ }
641
+ const dest = join(deck.imagesDir, hash);
642
+ if (!existsSync(dest)) copyFileSync(srcPath, dest);
643
+ }
package/mcp-server.mjs CHANGED
@@ -7,8 +7,15 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { z } from 'zod';
8
8
  import { FigDeck } from './lib/fig-deck.mjs';
9
9
  import { Deck } from './lib/api.mjs';
10
- import { nid, ov, nestedOv, removeNode, parseId, positionChar } from './lib/node-helpers.mjs';
11
- import { imageOv, hexToHash, hashToHex } from './lib/image-helpers.mjs';
10
+ import {
11
+ annotateTemplateLayout,
12
+ createDraftTemplate,
13
+ createFromTemplate,
14
+ listTemplateLayouts,
15
+ publishTemplateDraft,
16
+ } from './lib/template-deck.mjs';
17
+ import { nid, ov, removeNode } from './lib/node-helpers.mjs';
18
+ import { imageOv, hashToHex } from './lib/image-helpers.mjs';
12
19
  import { deepClone } from './lib/deep-clone.mjs';
13
20
 
14
21
  const server = new McpServer({
@@ -45,7 +52,7 @@ server.tool(
45
52
  // ── list-text ───────────────────────────────────────────────────────────
46
53
  server.tool(
47
54
  'figmatk_list_text',
48
- 'List all text and image content per slide in a .deck file',
55
+ 'List visible text and image content per slide in a .deck file, including direct slide nodes and instance overrides.',
49
56
  { path: z.string().describe('Path to .deck or .fig file') },
50
57
  async ({ path }) => {
51
58
  const deck = await FigDeck.fromDeckFile(path);
@@ -55,18 +62,41 @@ server.tool(
55
62
  if (slide.phase === 'REMOVED') continue;
56
63
  const id = nid(slide);
57
64
  lines.push(`\n── Slide ${id} "${slide.name || ''}" ──`);
65
+
66
+ const directLines = [];
67
+ deck.walkTree(id, (node, depth) => {
68
+ if (depth === 0 || node.phase === 'REMOVED') return;
69
+ if (node.type === 'TEXT' && node.textData?.characters) {
70
+ directLines.push(` [text-node] ${nid(node)} "${node.name || ''}": ${node.textData.characters.substring(0, 120)}`);
71
+ }
72
+ if (node.type === 'SHAPE_WITH_TEXT' && node.nodeGenerationData?.overrides) {
73
+ for (const override of node.nodeGenerationData.overrides) {
74
+ if (override.textData?.characters) {
75
+ directLines.push(` [shape-text] ${nid(node)} "${node.name || ''}": ${override.textData.characters.substring(0, 120)}`);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ const imageFill = node.fillPaints?.find(p => p.type === 'IMAGE' && p.image?.hash);
81
+ if (imageFill) {
82
+ directLines.push(` [image-node] ${nid(node)} "${node.name || ''}": ${hashToHex(imageFill.image.hash)}`);
83
+ }
84
+ });
85
+
86
+ lines.push(...directLines);
87
+
58
88
  const inst = deck.getSlideInstance(id);
59
89
  if (!inst?.symbolData?.symbolOverrides) continue;
60
90
  for (const ov of inst.symbolData.symbolOverrides) {
61
91
  const key = ov.guidPath?.guids?.[0];
62
92
  const keyStr = key ? `${key.sessionID}:${key.localID}` : '?';
63
93
  if (ov.textData?.characters) {
64
- lines.push(` [text] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
94
+ lines.push(` [text-override] ${keyStr}: ${ov.textData.characters.substring(0, 120)}`);
65
95
  }
66
96
  if (ov.fillPaints?.length) {
67
97
  for (const p of ov.fillPaints) {
68
98
  if (p.image?.hash) {
69
- lines.push(` [image] ${keyStr}: ${hashToHex(p.image.hash)}`);
99
+ lines.push(` [image-override] ${keyStr}: ${hashToHex(p.image.hash)}`);
70
100
  }
71
101
  }
72
102
  }
@@ -92,13 +122,13 @@ server.tool(
92
122
  function walkChildren(nodeId, depth) {
93
123
  const node = deck.getNode(nodeId);
94
124
  if (!node || node.phase === 'REMOVED') return;
95
- const cid = nid(node);
125
+ const key = node.overrideKey ? `${node.overrideKey.sessionID}:${node.overrideKey.localID}` : null;
96
126
  const type = node.type || '?';
97
127
  const name = node.name || '';
98
- if (type === 'TEXT' || (node.fillPaints?.some(p => p.type === 'IMAGE'))) {
99
- lines.push(` ${' '.repeat(depth)}${type} ${cid} "${name}"`);
128
+ if (key && (type === 'TEXT' || node.fillPaints?.some(p => p.type === 'IMAGE'))) {
129
+ lines.push(` ${' '.repeat(depth)}${type} ${key} "${name}"`);
100
130
  }
101
- const kids = deck.childrenMap.get(cid) || [];
131
+ const kids = deck.childrenMap.get(nid(node)) || [];
102
132
  for (const kid of kids) walkChildren(nid(kid), depth + 1);
103
133
  }
104
134
  for (const child of children) walkChildren(nid(child), 0);
@@ -126,7 +156,18 @@ server.tool(
126
156
 
127
157
  for (const [key, text] of Object.entries(overrides)) {
128
158
  const [s, l] = key.split(':').map(Number);
129
- inst.symbolData.symbolOverrides.push(ov({ sessionID: s, localID: l }, text));
159
+ const nextOverride = ov({ sessionID: s, localID: l }, text);
160
+ const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
161
+ entry.guidPath?.guids?.length >= 1 &&
162
+ entry.guidPath.guids[0].sessionID === s &&
163
+ entry.guidPath.guids[0].localID === l &&
164
+ entry.textData
165
+ );
166
+ if (existingIdx >= 0) {
167
+ inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
168
+ } else {
169
+ inst.symbolData.symbolOverrides.push(nextOverride);
170
+ }
130
171
  }
131
172
 
132
173
  const bytes = await deck.saveDeck(output);
@@ -157,9 +198,18 @@ server.tool(
157
198
  if (!inst.symbolData.symbolOverrides) inst.symbolData.symbolOverrides = [];
158
199
 
159
200
  const [s, l] = targetKey.split(':').map(Number);
160
- inst.symbolData.symbolOverrides.push(
161
- imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height)
201
+ const nextOverride = imageOv({ sessionID: s, localID: l }, imageHash, thumbHash, width, height);
202
+ const existingIdx = inst.symbolData.symbolOverrides.findIndex(entry =>
203
+ entry.guidPath?.guids?.length >= 1 &&
204
+ entry.guidPath.guids[0].sessionID === s &&
205
+ entry.guidPath.guids[0].localID === l &&
206
+ entry.fillPaints
162
207
  );
208
+ if (existingIdx >= 0) {
209
+ inst.symbolData.symbolOverrides.splice(existingIdx, 1, nextOverride);
210
+ } else {
211
+ inst.symbolData.symbolOverrides.push(nextOverride);
212
+ }
163
213
 
164
214
  const opts = imagesDir ? { imagesDir } : {};
165
215
  const bytes = await deck.saveDeck(output, opts);
@@ -336,6 +386,95 @@ server.tool(
336
386
  }
337
387
  );
338
388
 
389
+ // ── figmatk_list_template_layouts ────────────────────────────────────────
390
+ server.tool(
391
+ 'figmatk_create_template_draft',
392
+ 'Create a new draft template deck. Draft templates are normal slide decks; later annotate slots and publish-wrap them into module-backed layouts.',
393
+ {
394
+ output: z.string().describe('Output path for the draft template .deck file'),
395
+ title: z.string().describe('Template deck title'),
396
+ layouts: z.array(z.string()).optional().describe('Optional ordered list of layout names to create, e.g. ["cover", "agenda", "section"]'),
397
+ },
398
+ async ({ output, title, layouts }) => {
399
+ const bytes = await createDraftTemplate(output, { title, layouts });
400
+ return { content: [{ type: 'text', text: `Created draft template ${output} (${bytes} bytes). Use figmatk_annotate_template_layout to mark layout and slot names.` }] };
401
+ }
402
+ );
403
+
404
+ server.tool(
405
+ 'figmatk_annotate_template_layout',
406
+ 'Add explicit layout and slot metadata to a draft or published template. Use figmatk_inspect or figmatk_list_template_layouts first to get slide and node IDs.',
407
+ {
408
+ path: z.string().describe('Path to the source .deck file'),
409
+ output: z.string().describe('Output path for the updated .deck file'),
410
+ slideId: z.string().describe('Slide node ID to annotate'),
411
+ layoutName: z.string().optional().describe('Logical layout name without the layout: prefix, e.g. "cover"'),
412
+ textSlots: z.record(z.string()).optional().describe('Map of nodeId -> text slot name, e.g. {"1:120": "title"}'),
413
+ imageSlots: z.record(z.string()).optional().describe('Map of nodeId -> image slot name, e.g. {"1:144": "hero_image"}'),
414
+ fixedImages: z.record(z.string()).optional().describe('Map of nodeId -> fixed image label for decorative/sample content'),
415
+ },
416
+ async ({ path, output, slideId, layoutName, textSlots, imageSlots, fixedImages }) => {
417
+ const bytes = await annotateTemplateLayout(path, output, { slideId, layoutName, textSlots, imageSlots, fixedImages });
418
+ return { content: [{ type: 'text', text: `Annotated slide ${slideId}. Saved ${output} (${bytes} bytes).` }] };
419
+ }
420
+ );
421
+
422
+ server.tool(
423
+ 'figmatk_publish_template_draft',
424
+ 'Wrap draft template slides in publish-like MODULE nodes while preserving the slide subtree and internal canvas assets.',
425
+ {
426
+ path: z.string().describe('Path to the draft template .deck file'),
427
+ output: z.string().describe('Output path for the wrapped .deck file'),
428
+ slideIds: z.array(z.string()).optional().describe('Optional list of draft slide IDs to wrap. Defaults to every draft layout on the main canvas.'),
429
+ },
430
+ async ({ path, output, slideIds }) => {
431
+ const bytes = await publishTemplateDraft(path, output, { slideIds });
432
+ return { content: [{ type: 'text', text: `Publish-wrapped draft template to ${output} (${bytes} bytes).` }] };
433
+ }
434
+ );
435
+
436
+ server.tool(
437
+ 'figmatk_list_template_layouts',
438
+ 'Inspect a template or draft template .deck file and return available layouts with explicit text/image slot metadata. Call this before figmatk_create_from_template or figmatk_annotate_template_layout.',
439
+ {
440
+ template: z.string().describe('Path to the .deck template file'),
441
+ },
442
+ async ({ template }) => {
443
+ const layouts = await listTemplateLayouts(template);
444
+ const lines = layouts.map(l => {
445
+ const textSlots = l.textFields.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}): "${f.preview}"`).join('\n');
446
+ const imageSlots = l.imagePlaceholders.map(f => ` - ${f.name} (${f.nodeId}, ${f.source}, ${f.width}x${f.height})${f.hasCurrentImage ? ' [image]' : ''}`).join('\n');
447
+ return [
448
+ `Layout "${l.name}" [${l.slideId}]`,
449
+ ` state: ${l.state}${l.moduleId ? `, module ${l.moduleId}` : ''}, row ${l.rowId}`,
450
+ ` explicit slots: ${l.hasExplicitSlotMetadata ? 'yes' : 'no'}`,
451
+ textSlots ? ` text slots:\n${textSlots}` : ' text slots: (none)',
452
+ imageSlots ? ` image slots:\n${imageSlots}` : ' image slots: (none)',
453
+ ].join('\n');
454
+ });
455
+ return { content: [{ type: 'text', text: lines.join('\n\n') }] };
456
+ }
457
+ );
458
+
459
+ // ── figmatk_create_from_template ─────────────────────────────────────────
460
+ server.tool(
461
+ 'figmatk_create_from_template',
462
+ 'Create a new Figma Slides deck by cherry-picking layouts from a draft, published, or publish-like template .deck file and populating explicit text/image slots. Preserves colors, fonts, internal assets, and special nodes.',
463
+ {
464
+ template: z.string().describe('Path to the source .deck template file'),
465
+ output: z.string().describe('Output path for the new .deck file (use /tmp/)'),
466
+ slides: z.array(z.object({
467
+ slideId: z.string().describe('Slide ID from figmatk_list_template_layouts (e.g. "1:74")'),
468
+ text: z.record(z.string()).optional().describe('Map of text slot/name/nodeId -> value (e.g. { "title": "My Company" })'),
469
+ images: z.record(z.string()).optional().describe('Map of image slot/name/nodeId -> absolute image path (e.g. { "hero_image": "/tmp/photo.jpg" })'),
470
+ })).describe('Ordered list of slides to include, each referencing a template layout'),
471
+ },
472
+ async ({ template, output, slides }) => {
473
+ const bytes = await createFromTemplate(template, output, slides);
474
+ return { content: [{ type: 'text', text: `Created ${output} — ${slides.length} slides (${bytes} bytes). Open in Figma Desktop.` }] };
475
+ }
476
+ );
477
+
339
478
  // ── Start server ────────────────────────────────────────────────────────
340
479
  const transport = new StdioServerTransport();
341
480
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figmatk",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Figma Toolkit — Swiss-army knife CLI for Figma .deck and .fig files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,16 +1,30 @@
1
1
  ---
2
2
  name: figma-slides-creator
3
3
  description: >
4
- Create, edit, and inspect Figma Slides .deck files. Use when the user asks to
5
- create a presentation, build a slide deck, edit slides, update text or images,
6
- clone or remove slides, or produce a .deck file for Figma Slides.
4
+ Create, populate, edit, and inspect Figma Slides .deck files. Use when the
5
+ user wants a finished presentation deck, wants to fill an existing template
6
+ with content, or wants to edit a non-template deck's text, images, or slide
7
+ order. Do not use this skill to author reusable templates themselves.
7
8
  Powered by FigmaTK under the hood.
8
9
  metadata:
9
- version: "0.2.6"
10
+ version: "0.3.1"
10
11
  ---
11
12
 
12
13
  # Figma Slides Creator
13
14
 
15
+ Use this skill for the default workflow: take an existing template and build a new presentation from it. For authoring reusable templates themselves, use `skills/figma-template-builder/SKILL.md`.
16
+
17
+ ## Skill Boundary
18
+
19
+ Use this skill when the outcome is a finished deck for immediate use.
20
+
21
+ Switch to `skills/figma-template-builder/SKILL.md` when the user wants to:
22
+
23
+ - build a reusable template
24
+ - define layouts or placeholders
25
+ - rename slots for future sessions
26
+ - derive a new template system from references or examples
27
+
14
28
  ## ⚠️ Never open .deck files directly
15
29
 
16
30
  `.deck` files are binary ZIP archives. **Never open, read, or display a `.deck` file** — it will show garbage bytes in the panel. To inspect or modify a `.deck` file, always use the CLI commands or Node.js API shown below.
@@ -23,7 +37,9 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
23
37
 
24
38
  | Task | Approach |
25
39
  |------|----------|
26
- | Create a new deck from scratch | **`figmatk_create_deck` MCP tool** — no npm install needed |
40
+ | Create from scratch | **Path A** — `figmatk_create_deck` MCP tool |
41
+ | Create from a `.deck` template | **Path B** — `figmatk_list_template_layouts` + `figmatk_create_from_template` |
42
+ | Author a reusable template | Use `skills/figma-template-builder/SKILL.md` |
27
43
  | Edit text or images in an existing deck | `figmatk_update_text`, `figmatk_insert_image` |
28
44
  | Clone, remove, or restructure slides | `figmatk_clone_slide`, `figmatk_remove_slide` |
29
45
  | Inspect structure or read content | `figmatk_inspect`, `figmatk_list_text` |
@@ -34,9 +50,58 @@ To let the user view the result: tell them to **open the file in Figma Desktop**
34
50
 
35
51
  **All files go in `/tmp/`** — scripts, output decks, images, everything. Never write to the Desktop, Documents, Downloads, or any user directory. Never create intermediate notes or reference markdown files. Just build and save the deck.
36
52
 
53
+ ## Default Workflow
54
+
55
+ 1. Inspect the template or deck.
56
+ 2. Pick the minimum set of layouts or edits needed.
57
+ 3. Populate text slots first, then image slots.
58
+ 4. Save to a new `/tmp/` output path.
59
+ 5. Sanity-check the result with `figmatk_list_text` or by opening it in Figma Desktop.
60
+
61
+ ---
62
+
63
+ ## Path B — Create from a Template (preferred when user provides a .deck file)
64
+
65
+ Use this path when the user provides a `.deck` template file. The output deck inherits all fonts, colors, spacing, and visual design from the template verbatim.
66
+
67
+ ### Step 1 — Inspect the template
68
+
69
+ ```
70
+ figmatk_list_template_layouts("/path/to/template.deck")
71
+ ```
72
+
73
+ Returns a catalog of all available slide layouts. Each entry includes:
74
+ - `slideId` — the ID to reference this layout
75
+ - Layout state — `draft` or `published`
76
+ - Text slots — explicit `slot:text:*` fields when present, otherwise fallback text candidates
77
+ - Image slots — explicit `slot:image:*` fields when present, otherwise fallback image candidates
78
+ - Node IDs — usable for direct targeting when the template has not been fully annotated yet
79
+
80
+ **Read the catalog carefully before picking layouts:**
81
+ - Prefer layouts with explicit slot metadata when available
82
+ - Match each slide's purpose to your content; the existing copy is often the best hint
83
+ - Use slot names first, then node IDs, then raw node names when populating content
84
+ - If a layout exposes no explicit image slots, treat heuristic image candidates as weaker signals and avoid overwriting decorative sample imagery unless the user clearly wants that
85
+
86
+ ### Step 2 — Create the deck
87
+
88
+ ```
89
+ figmatk_create_from_template({
90
+ template: "/path/to/template.deck",
91
+ output: "/tmp/my-deck.deck",
92
+ slides: [
93
+ { slideId: "1:74", text: { "title": "My Company" } },
94
+ { slideId: "1:112", text: { "header": "The problem.", "body": "Description here." }, images: { "hero_image": "/tmp/problem-photo.jpg" } },
95
+ { slideId: "1:643", text: { "title": "Thank you!" } }
96
+ ]
97
+ })
98
+ ```
99
+
100
+ Only pass slots or node IDs that exist in the layout's catalog. Extra keys are silently ignored.
101
+
37
102
  ---
38
103
 
39
- ## Path A — Create from Scratch (MCP tool — preferred)
104
+ ## Path A — Create from Scratch (MCP tool — no template)
40
105
 
41
106
  **Always use this path.** No npm install, no scripts, no workspace setup.
42
107
 
@@ -211,6 +276,14 @@ Use this when the user provides a `.deck` file to modify.
211
276
  | `figmatk_remove_slide` | Mark slides as REMOVED (never deleted) |
212
277
  | `figmatk_roundtrip` | Decode + re-encode for pipeline validation |
213
278
 
279
+ ## Final Checks
280
+
281
+ Before finishing, prefer at least one of these:
282
+
283
+ - `figmatk_list_text` on the output deck
284
+ - `figmatk_roundtrip` if the deck went through multiple edits
285
+ - a manual open check in Figma Desktop when the user is validating upload/render behavior
286
+
214
287
  ---
215
288
 
216
289
  ## Design Philosophy
@@ -289,7 +362,7 @@ Every slide needs at least **one visual element** — shape, image, SVG, or tabl
289
362
  - Centre body text
290
363
  - Use accent lines under slide titles (hallmark of AI-generated slides)
291
364
  - Text-only slides
292
- - Low-contrast text against background
365
+ - Low-contrast text against background — **match image tone to slide palette**: dark/moody images on light-background slides make text unreadable; pick a bright image or switch to a dark-background layout
293
366
  - Skip the closing slide — it makes the deck feel unfinished
294
367
  - Put long paragraphs in body/caption fields — text overflows the container
295
368
 
@@ -0,0 +1,148 @@
1
+ ---
2
+ name: figma-template-builder
3
+ description: >
4
+ Author reusable Figma Slides templates as .deck files. Use when the user
5
+ wants to build a template from reference images or examples, derive a new
6
+ template from an existing deck, define reusable layouts, mark editable
7
+ text/image slots, or prepare a draft template for later instantiation.
8
+ metadata:
9
+ version: "0.1.0"
10
+ ---
11
+
12
+ # Figma Template Builder
13
+
14
+ Use this skill when the goal is to build or refine the template itself. For the common workflow of taking an existing template and producing a new presentation, use `skills/figma-slides-creator/SKILL.md`.
15
+
16
+ ## Skill Boundary
17
+
18
+ Use this skill when the deliverable is a reusable template, not a one-off deck.
19
+
20
+ Stay here when the user wants to:
21
+
22
+ - translate reference images or screenshots into template layouts
23
+ - create a new layout family
24
+ - define placeholder semantics for later sessions
25
+ - turn an ordinary deck into a reusable template
26
+
27
+ Hand off to `skills/figma-slides-creator/SKILL.md` once the template exists and the task becomes simple content population.
28
+
29
+ ## Core Model
30
+
31
+ Template work happens in two states:
32
+
33
+ - Draft template: `SLIDE_ROW -> SLIDE -> ...`
34
+ - Published or publish-like template: `SLIDE_ROW -> MODULE -> SLIDE -> ...`
35
+
36
+ Draft templates are easier to author. Publish-like wrapping is the final step before later instantiation.
37
+
38
+ ## Design-First Workflow
39
+
40
+ When the user provides reference images, screenshots, or example decks:
41
+
42
+ 1. Read the references first and infer the layout family before touching the `.deck`.
43
+ 2. Decide which parts are reusable structure versus one-off sample content.
44
+ 3. Create only the smallest set of layouts that captures the system.
45
+ 4. Use semantic slot names so later sessions can populate them without re-reading the design intent.
46
+
47
+ Do not mirror every visual variation as its own layout unless the content structure changes materially.
48
+
49
+ ## Naming Conventions
50
+
51
+ Always use explicit metadata when authoring reusable layouts:
52
+
53
+ - Layouts: `layout:<name>`
54
+ - Text slots: `slot:text:<name>`
55
+ - Image slots: `slot:image:<name>`
56
+ - Decorative fixed imagery: `fixed:image:<name>`
57
+
58
+ These conventions are how later sessions discover what is editable.
59
+
60
+ Prefer semantic names over visual or auto-generated names.
61
+
62
+ Good:
63
+
64
+ - `title`
65
+ - `subtitle`
66
+ - `hero_image`
67
+ - `device_screen_primary`
68
+ - `quote_author`
69
+
70
+ Bad:
71
+
72
+ - `text1`
73
+ - `frame183`
74
+ - `image-left`
75
+ - `rectangle2`
76
+
77
+ ## Recommended Flow
78
+
79
+ ### 1. Create or inspect a draft template
80
+
81
+ - New draft from scratch: `figmatk_create_template_draft`
82
+ - Existing deck/template: `figmatk_list_template_layouts`
83
+ - Structural inspection: `figmatk_inspect`
84
+
85
+ ### 2. Annotate reusable layouts
86
+
87
+ Use `figmatk_annotate_template_layout` to:
88
+
89
+ - rename a slide as a layout
90
+ - mark text nodes as editable text slots
91
+ - mark image-bearing nodes as editable image slots
92
+ - mark decorative imagery as fixed
93
+
94
+ The tool accepts node ID maps, so inspect first if you need the raw node IDs.
95
+
96
+ While annotating:
97
+
98
+ - rename the slide itself to the stable layout name
99
+ - mark only true placeholders as `slot:*`
100
+ - mark decorative or sample imagery as `fixed:image:*`
101
+ - prefer stable semantic names over spatial names like `left_box`
102
+
103
+ ### 3. Publish-wrap when the template is ready
104
+
105
+ Use `figmatk_publish_template_draft` to add publish-like `MODULE` wrappers while preserving the slide subtree and internal assets.
106
+
107
+ ### 4. Verify the result
108
+
109
+ - `figmatk_list_template_layouts`
110
+ - `figmatk_list_text`
111
+ - `figmatk_roundtrip` if you want a conservative encode/decode check
112
+ - open the wrapped template in Figma Desktop when validating real upload behavior
113
+
114
+ ## Practical Rules
115
+
116
+ - Prefer explicit slot names over heuristic placeholders.
117
+ - Do not assume every image fill is editable content.
118
+ - Preserve `Internal Only Canvas` assets.
119
+ - Preserve special nodes such as device mockups and interactive slide elements; do not try to recreate them from scratch unless necessary.
120
+
121
+ ## Template Authoring Heuristics
122
+
123
+ - Start with 4-8 reusable layouts, not an exhaustive library.
124
+ - Reuse one layout when only copy length changes; create a new layout when hierarchy or media structure changes.
125
+ - Separate content slots from chrome. For device mockups, the screen is usually the slot and the hardware frame is usually fixed.
126
+ - If a layout has explicit slot metadata, do not rely on heuristic image placeholders for that layout.
127
+ - After publish-wrapping, re-run `figmatk_list_template_layouts` and confirm the layout names and slot names survived unchanged.
128
+
129
+ ## Example
130
+
131
+ ```json
132
+ {
133
+ "path": "/tmp/draft-template.deck",
134
+ "output": "/tmp/draft-template-annotated.deck",
135
+ "slideId": "1:42",
136
+ "layoutName": "cover",
137
+ "textSlots": {
138
+ "1:120": "title",
139
+ "1:121": "subtitle"
140
+ },
141
+ "imageSlots": {
142
+ "1:144": "hero_image"
143
+ },
144
+ "fixedImages": {
145
+ "1:199": "background_texture"
146
+ }
147
+ }
148
+ ```