@se-studio/skills 1.0.6 → 1.0.11

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.
@@ -0,0 +1,222 @@
1
+ ---
2
+ name: site-workflows-copy-doc-snapshot
3
+ description: Syncs content/copy/*.md files from the Google Docs copy document. Run whenever the copy doc is updated to pull changes into git. Produces a diff-friendly merge — CMS annotations and sync-to-CMS state are never touched.
4
+ license: MIT
5
+ metadata:
6
+ author: se-studio
7
+ version: "1.0.0"
8
+ ---
9
+
10
+ # Copy Doc Snapshot
11
+
12
+ Pulls the current state of the Google Docs copy document and merges it into the local `content/copy/*.md` files. Running the skill twice with no doc changes produces no diff — only real copy edits show up in git.
13
+
14
+ ## Usage
15
+
16
+ ```
17
+ /copy-doc-snapshot
18
+ ```
19
+
20
+ No arguments — reads config from `.copy-doc-sync.json` in the project root.
21
+
22
+ ---
23
+
24
+ ## Step 1 — Read config
25
+
26
+ Read `.copy-doc-sync.json` from the project root. Shape:
27
+
28
+ ```json
29
+ {
30
+ "googleDocId": "...",
31
+ "outputDir": "content/copy",
32
+ "pages": {
33
+ "Homepage": { "file": "homepage.md", "slug": "/", "title": "Site Homepage" }
34
+ }
35
+ }
36
+ ```
37
+
38
+ If the file does not exist, stop and tell the user: "`.copy-doc-sync.json` not found. Create it in the project root with a `googleDocId` and `pages` map."
39
+
40
+ ---
41
+
42
+ ## Step 2 — Fetch the Google Doc
43
+
44
+ Call `mcp__claude_ai_Google_Drive__read_file_content` with `fileId` set to `googleDocId` from config.
45
+
46
+ If the call fails, stop immediately — do not proceed with stale or partial data. Report the error.
47
+
48
+ Store the full document text as `docText`.
49
+
50
+ ---
51
+
52
+ ## Step 3 — Parse the doc into pages and sections
53
+
54
+ The Google Docs MCP returns the document as markdown-like plain text. The structure uses:
55
+
56
+ - `# Page Name` — H1 line = page boundary. The text after `#` is the page name.
57
+ - `**Section Name**` — A standalone bold line (nothing before or after the `**...**` on the line) = component/section heading.
58
+ - `**Field:** value` or `**Field:**` (labeled line) — field label. Value follows on the same line or on the next non-empty line.
59
+ - Plain text lines after a `**Copy:**` or `**Header:**` label = field value content.
60
+ - `\[placeholder\]` and `TBC` in field values = content not yet confirmed.
61
+
62
+ **Stop processing at `# Current website copy`** — everything from that H1 onwards is reference material and must be ignored.
63
+
64
+ ### Parsing pass
65
+
66
+ Split `docText` on H1 headings (`^# .+`) to get page segments. For each page segment:
67
+
68
+ 1. The page name is the H1 text (trimmed, without the `#`).
69
+ 2. Split the page body on standalone bold lines (`^\*\*[A-Z][^*:]+\*\*\s*$`) to get section segments.
70
+ - A "standalone bold line" is bold text with no other content on the same line and no colon inside it.
71
+ 3. For each section segment, extract:
72
+ - `name` — the bold line text (strip `**` markers)
73
+ - `heading` — value after `**Header:**` label (same line or next non-empty line); `null` if not present
74
+ - `subheading` — the first plain-text line after the heading line that is NOT a labeled field; `null` if not present. (Used to detect section-level subtitles like "Studio technology, evidence generation...")
75
+ - `body` — all lines between `**Copy:**` and the next labeled field or section header; joined as paragraphs
76
+ - `links` — all lines between `**CTAs:**` and the next labeled field or section header; one link per line
77
+ - `items` — if the body contains sub-section structure (nested bold names like `**Studio**`, `**Evidence**`), extract each as a sub-item with its own `name`, `subheading`, and `body`
78
+ - `isTBC` — `true` if the `heading` value is literally `TBC` AND the `body` is empty or also `TBC`
79
+
80
+ ### Recognising collection items within a section
81
+
82
+ Some sections (e.g. "What We Do", "Featured Research", "From The Blog") contain multiple items structured as:
83
+
84
+ ```
85
+ **Item Name**
86
+ Subtitle line (optional)
87
+ Body copy here.
88
+ ```
89
+
90
+ Detect this pattern: after the section-level `**Header:**` and `**Copy:**`, if the body contains lines that are standalone bold (`**...**`) followed by non-bold lines, treat each bold+following-text block as a separate item with:
91
+ - `name` = bold text
92
+ - `subheading` = first non-bold line (if short and no verb — likely a descriptor like "Technology & software")
93
+ - `body` = remaining non-bold lines before the next bold item
94
+
95
+ ---
96
+
97
+ ## Step 4 — For each page in config, merge doc content into the markdown file
98
+
99
+ Work through each entry in `config.pages`. For each page:
100
+
101
+ ### 4a. Locate doc content
102
+
103
+ Find the parsed page segment whose name matches the config key (case-insensitive). If not found, note it in the report as "not found in doc" and skip to the next page — do not modify the file.
104
+
105
+ ### 4b. Read the existing markdown file
106
+
107
+ Read `{outputDir}/{file}`. If it does not exist, create a minimal stub:
108
+
109
+ ```markdown
110
+ ---
111
+ slug: {slug}
112
+ title: {title}
113
+ description: TBC
114
+ indexed: true
115
+ ---
116
+
117
+ # {title}
118
+
119
+ > Last synced from Google Docs: never
120
+ > Last synced to CMS: never
121
+ ```
122
+
123
+ Then proceed to 4c using just the doc content (no existing sections to merge with).
124
+
125
+ If it does exist, parse it to identify:
126
+ - The YAML frontmatter block (lines between first `---` pair)
127
+ - The `> Last synced from Google Docs:` line (may be absent — treat as `never`)
128
+ - The `> Last synced to CMS:` line
129
+ - Each `## Section` block with its `<!-- ... -->` annotation line and all fields
130
+ - Each `### Item` block within collections, with their `<!-- item ... -->` annotation lines
131
+
132
+ ### 4c. Merge doc sections into existing file structure
133
+
134
+ For each `## Section` in the existing file:
135
+
136
+ **Case 1 — Section found in doc (name match, case-insensitive):**
137
+ - Update `**heading:**` with doc `heading` value (or remove the field if `null`)
138
+ - Update `**body:**` with doc `body` text, reflowed as markdown paragraphs
139
+ - Update `**links:**` with doc `links`, one bullet per line, preserving any `(internal: ...)` or `(external: ...)` annotations that already exist in the file for links with matching labels
140
+ - For collection sections with items: recursively apply the same logic to each `### Item` — match by item name, update `heading`, `preHeading` (from doc `subheading`), `body`
141
+ - **Preserve unchanged:** the `<!-- component: ... | cmsLabel: ... -->` annotation, the `<!-- item | cmsLabel: ... -->` annotations, any `**preHeading:**` fields not provided by the doc, and any `<!-- note: ... -->` lines already present
142
+
143
+ **Case 2 — Section is TBC in doc (`isTBC` is true):**
144
+ - Do NOT update any content fields — leave them exactly as they are
145
+ - Add `<!-- note: TBC in Google Doc as of {today} -->` on the line after the `<!-- component: ... -->` annotation, but only if such a note is not already present
146
+
147
+ **Case 3 — Section exists in file but is NOT in the doc:**
148
+ - Preserve the section and all its content unchanged
149
+ - Add `<!-- note: Not found in Google Doc as of {today} — verify still needed -->` after the section's annotation comment, but only if such a note is not already present
150
+ - Flag this section in the report
151
+
152
+ **Case 4 — Section exists in doc but NOT in the existing file:**
153
+ - Append a new `## Section` block at the end of the file (before any Footer section) using the doc content
154
+ - Use this annotation as placeholder: `<!-- component: Generic Component | cmsLabel: {SECTION_NAME} — {PAGE_TITLE} -->`
155
+ - Flag this in the report as "new section — annotation needs review"
156
+
157
+ ### 4d. Preserve these elements unchanged in all cases
158
+
159
+ - The entire YAML frontmatter block
160
+ - `> Last synced to CMS:` line (never touch this)
161
+ - `<!-- component: ... | cmsLabel: ... -->` annotation content
162
+ - `<!-- item | cmsLabel: ... -->` annotation content
163
+ - Any `<!-- note: ... -->` lines already in the file (do not duplicate them)
164
+ - The `## Footer` section (it is reference-only and managed separately via `cms-edit nav`)
165
+
166
+ ### 4e. Update the sync date
167
+
168
+ Get today's date: run `date -u +"%Y-%m-%d"` via Bash. Update the `> Last synced from Google Docs:` line to this date. If the line does not exist in the file, insert it immediately after the H1 heading line and before the first `---` separator.
169
+
170
+ ---
171
+
172
+ ## Step 5 — Write files
173
+
174
+ For each page, compare the updated content to the file on disk. If the content is identical (character-for-character), skip writing — do not produce a noisy no-op diff.
175
+
176
+ Otherwise, write the updated content to the file path. Do not reformat YAML frontmatter or alter indentation in sections that were not modified.
177
+
178
+ ---
179
+
180
+ ## Step 6 — Report
181
+
182
+ After all pages are processed, print a summary in this format:
183
+
184
+ ```
185
+ copy-doc-snapshot complete (YYYY-MM-DD)
186
+
187
+ homepage.md — updated: Hero body, Trusted By body
188
+ about-us.md — no changes (TBC in doc)
189
+ contact-us.md — no changes (TBC in doc)
190
+
191
+ Attention needed:
192
+ homepage.md › Footer: not found in Google Doc — verify still needed
193
+ homepage.md › Services: new section in doc — annotation placeholder added, review cmsLabel
194
+
195
+ New pages in doc not in config (add to .copy-doc-sync.json to enable syncing):
196
+ (none)
197
+ ```
198
+
199
+ If there are no attention items, omit the "Attention needed" block.
200
+
201
+ Finally, suggest the commit message: `git add content/copy && git commit -m "copy: sync from Google Doc (YYYY-MM-DD)"`
202
+
203
+ ---
204
+
205
+ ## Idempotency rules
206
+
207
+ Running the skill twice on the same day with no doc changes must produce identical files and an identical report:
208
+
209
+ - Never include anything in file content that changes between runs except the `> Last synced from Google Docs:` date line — and that only changes once per day
210
+ - Do not reorder sections, items, or links relative to their existing order in the file
211
+ - Preserve exact whitespace in sections that were not modified
212
+ - Do not add duplicate `<!-- note: ... -->` comments — check if the note already exists before inserting
213
+
214
+ ---
215
+
216
+ ## Notes
217
+
218
+ - The skill **never touches** `> Last synced to CMS:` — that line is owned by the `contentful-cms-core` workflow
219
+ - The skill **never creates** new page files unless they were already in config — a new page in the doc requires a human to add it to `.copy-doc-sync.json` with its slug
220
+ - The `# Current website copy` section in the doc is legacy reference material — always ignore it and everything after it
221
+ - Bold lines that are subtitles (e.g., `**Homepage copy**`) rather than section headings can be identified because they appear immediately after a page H1 and contain no matching section in the file — ignore them
222
+ - The `.copy-doc-sync.json` config should be committed to git so any team member can regenerate the snapshot
@@ -0,0 +1,390 @@
1
+ ---
2
+ name: "Figma Design Snapshot"
3
+ description: "Regenerates design.md from Figma by extracting all design tokens, typography, and component specs. Produces a deterministic, git-diffable output — run on any day to see what designers have changed."
4
+ metadata:
5
+ author: se-studio
6
+ version: "1.0.0"
7
+ ---
8
+
9
+ # Figma Design Snapshot
10
+
11
+ Regenerates `design.md` from Figma. Every run produces output in the same format and order, so `git diff design.md` shows exactly what designers changed between snapshots.
12
+
13
+ ## Usage
14
+
15
+ ```
16
+ /site-workflows-figma-design-snapshot
17
+ ```
18
+
19
+ No arguments needed — reads config from `.design-snapshot.json` in the project root.
20
+
21
+ ---
22
+
23
+ ## Step 1 — Read config
24
+
25
+ Read `.design-snapshot.json` from the project root. It has this shape:
26
+
27
+ ```json
28
+ {
29
+ "figmaFileKey": "<your-figma-file-key>",
30
+ "figmaPageId": "<page-node-id>",
31
+ "outputPath": "design.md",
32
+ "componentFrameIds": {
33
+ "Components": "<frame-node-id>"
34
+ }
35
+ }
36
+ ```
37
+
38
+ If the file does not exist, ask the user:
39
+ - Figma file URL (extract `fileKey` from it)
40
+ - Which frame on the page contains the component library (name and node ID)
41
+ - Desired output path (default: `design.md`)
42
+
43
+ Then write `.design-snapshot.json` before continuing.
44
+
45
+ ---
46
+
47
+ ## Step 2 — Extract tokens via Figma Plugin API
48
+
49
+ Call `use_figma` with the `fileKey` from config and the following JavaScript. This extracts all design tokens deterministically. **Do not deviate from this script** — it produces the stable, sorted output that makes git diffs useful.
50
+
51
+ ```javascript
52
+ function round1(n) { return Math.round(n * 10) / 10; }
53
+
54
+ function hexFromColor(c, opacity) {
55
+ const hex = '#' + [c.r, c.g, c.b]
56
+ .map(v => Math.round(v * 255).toString(16).padStart(2, '0'))
57
+ .join('');
58
+ if (opacity !== undefined && Math.round(opacity * 100) < 100) {
59
+ return hex + ' / ' + Math.round(opacity * 100) + '%';
60
+ }
61
+ return hex;
62
+ }
63
+
64
+ // — Colors (sorted alphabetically by name) —
65
+ const colors = figma.getLocalPaintStyles()
66
+ .map(s => {
67
+ const p = s.paints[0];
68
+ if (!p) return null;
69
+ return {
70
+ name: s.name,
71
+ type: p.type,
72
+ value: p.type === 'SOLID' ? hexFromColor(p.color, p.opacity) : p.type,
73
+ };
74
+ })
75
+ .filter(Boolean)
76
+ .sort((a, b) => a.name.localeCompare(b.name));
77
+
78
+ // — Text styles (sorted alphabetically by name) —
79
+ const typeStyles = figma.getLocalTextStyles()
80
+ .map(s => ({
81
+ name: s.name,
82
+ family: s.fontName.family,
83
+ style: s.fontName.style,
84
+ size: s.fontSize,
85
+ lineHeight: s.lineHeight.unit === 'PERCENT'
86
+ ? Math.round(s.lineHeight.value) + '%'
87
+ : s.lineHeight.unit === 'PIXELS'
88
+ ? round1(s.lineHeight.value) + 'px'
89
+ : 'AUTO',
90
+ letterSpacing: s.letterSpacing.unit === 'PERCENT'
91
+ ? round1(s.letterSpacing.value) + '%'
92
+ : round1(s.letterSpacing.value) + 'px',
93
+ textCase: s.textCase,
94
+ }))
95
+ .sort((a, b) => a.name.localeCompare(b.name));
96
+
97
+ // — Variables (sorted by collection/name) —
98
+ const varCollections = figma.variables.getLocalVariableCollections();
99
+ const vars = figma.variables.getLocalVariables()
100
+ .map(v => {
101
+ const col = varCollections.find(vc => vc.variableIds.includes(v.id));
102
+ const values = {};
103
+ for (const [modeId, val] of Object.entries(v.valuesByMode)) {
104
+ const mode = col?.modes.find(m => m.modeId === modeId);
105
+ const key = mode?.name || modeId;
106
+ if (val && typeof val === 'object' && 'type' in val && val.type === 'VARIABLE_ALIAS') {
107
+ const ref = figma.variables.getVariableById(val.id);
108
+ values[key] = '→ ' + (ref?.name || val.id);
109
+ } else if (val && typeof val === 'object' && 'r' in val) {
110
+ values[key] = hexFromColor(val);
111
+ } else {
112
+ values[key] = String(val);
113
+ }
114
+ }
115
+ return {
116
+ collection: col?.name || '',
117
+ name: v.name,
118
+ type: v.resolvedType,
119
+ values,
120
+ };
121
+ })
122
+ .sort((a, b) => (a.collection + '/' + a.name).localeCompare(b.collection + '/' + b.name));
123
+
124
+ // — Top-level frames on this page (sorted by name) —
125
+ const frames = figma.currentPage.children
126
+ .filter(n => n.type === 'FRAME' || n.type === 'COMPONENT' || n.type === 'COMPONENT_SET')
127
+ .map(n => ({ id: n.id, name: n.name, width: Math.round(n.width), height: Math.round(n.height) }))
128
+ .sort((a, b) => a.name.localeCompare(b.name));
129
+
130
+ return JSON.stringify({ colors, typeStyles, vars, frames }, null, 2);
131
+ ```
132
+
133
+ Store the parsed result as `tokens`.
134
+
135
+ ---
136
+
137
+ ## Step 3 — Extract component frame properties
138
+
139
+ For each entry in `componentFrameIds` from config, call `use_figma` with this script, replacing `TARGET_ID` with the frame's node ID:
140
+
141
+ ```javascript
142
+ function round1(n) { return Math.round(n * 10) / 10; }
143
+
144
+ function hexFromColor(c, a) {
145
+ const hex = '#' + [c.r, c.g, c.b]
146
+ .map(v => Math.round(v * 255).toString(16).padStart(2, '0'))
147
+ .join('');
148
+ return (a !== undefined && Math.round(a * 100) < 100) ? hex + ' / ' + Math.round(a * 100) + '%' : hex;
149
+ }
150
+
151
+ function extractLayout(node) {
152
+ const out = {
153
+ id: node.id,
154
+ name: node.name,
155
+ type: node.type,
156
+ width: round1(node.width),
157
+ height: round1(node.height),
158
+ };
159
+
160
+ // Border radius
161
+ if (typeof node.cornerRadius === 'number' && node.cornerRadius > 0) {
162
+ out.cornerRadius = node.cornerRadius;
163
+ } else if ('topLeftRadius' in node) {
164
+ const radii = [node.topLeftRadius, node.topRightRadius, node.bottomRightRadius, node.bottomLeftRadius];
165
+ if (radii.some(r => r > 0)) out.cornerRadius = radii.join(' ');
166
+ }
167
+
168
+ // Fill colours
169
+ if ('fills' in node && Array.isArray(node.fills)) {
170
+ const fills = node.fills.filter(f => f.visible !== false && f.type === 'SOLID');
171
+ if (fills.length > 0) out.fill = hexFromColor(fills[0].color, fills[0].opacity);
172
+ }
173
+
174
+ // Strokes / borders
175
+ if ('strokes' in node && node.strokes.length > 0) {
176
+ const strokes = node.strokes.filter(s => s.visible !== false && s.type === 'SOLID');
177
+ if (strokes.length > 0) {
178
+ out.stroke = hexFromColor(strokes[0].color);
179
+ // Individual stroke weights (three-sided buttons etc.)
180
+ if ('strokeTopWeight' in node) {
181
+ out.strokeWeights = {
182
+ top: node.strokeTopWeight,
183
+ right: node.strokeRightWeight,
184
+ bottom: node.strokeBottomWeight,
185
+ left: node.strokeLeftWeight,
186
+ };
187
+ } else if ('strokeWeight' in node) {
188
+ out.strokeWeight = node.strokeWeight;
189
+ }
190
+ }
191
+ }
192
+
193
+ // Auto-layout (padding + gap)
194
+ if ('layoutMode' in node && node.layoutMode !== 'NONE') {
195
+ out.layout = {
196
+ direction: node.layoutMode,
197
+ paddingTop: node.paddingTop,
198
+ paddingRight: node.paddingRight,
199
+ paddingBottom: node.paddingBottom,
200
+ paddingLeft: node.paddingLeft,
201
+ gap: node.itemSpacing,
202
+ wrap: node.layoutWrap === 'WRAP',
203
+ };
204
+ }
205
+
206
+ // Recurse into named children (skip hidden, unnamed, or pure graphic nodes)
207
+ if ('children' in node) {
208
+ const namedChildren = node.children.filter(c =>
209
+ c.visible !== false &&
210
+ c.name &&
211
+ !c.name.startsWith('_') &&
212
+ (c.type === 'FRAME' || c.type === 'COMPONENT' || c.type === 'INSTANCE' || c.type === 'GROUP')
213
+ );
214
+ if (namedChildren.length > 0) {
215
+ out.children = namedChildren.map(extractLayout);
216
+ }
217
+ }
218
+
219
+ return out;
220
+ }
221
+
222
+ const node = figma.getNodeById('TARGET_ID');
223
+ if (!node) return JSON.stringify({ error: 'Node not found: TARGET_ID' });
224
+ return JSON.stringify(extractLayout(node), null, 2);
225
+ ```
226
+
227
+ Store results as `componentFrames[frameName]`.
228
+
229
+ ---
230
+
231
+ ## Step 4 — Get today's date
232
+
233
+ Run `date -u +"%Y-%m-%d"` via Bash to get today's UTC date. Store as `snapshotDate`.
234
+
235
+ ---
236
+
237
+ ## Step 5 — Write design.md
238
+
239
+ Write the output file to `outputPath` from config. Use **exactly this structure and section order** every run — consistent ordering is what makes git diffs useful.
240
+
241
+ ### Rules for determinism
242
+
243
+ - **Colors**: output in alphabetical order by `name`. Within each group (Primary / Neutral), sort alphabetically.
244
+ - **Typography**: output sorted by `name` (alphabetical). Because names follow the `DSK/H1` / `MBL/H1` pattern, this naturally groups desktop then mobile.
245
+ - **Variables**: output sorted by `collection/name`.
246
+ - **Frame inventory**: sorted by name.
247
+ - **Numbers**: always round to 1 decimal place. Drop `.0` (e.g. `12` not `12.0`). Exception: font sizes are always whole numbers.
248
+ - **Never include Figma asset URLs** — they expire in 7 days and will always produce diff noise.
249
+ - **No screenshots** — text and tables only.
250
+
251
+ ### Output template
252
+
253
+ ```markdown
254
+ # [Page Name] — Design Reference
255
+
256
+ > Source: Figma file `[fileKey]` · Page `[figmaPageId]` · Snapshot: [snapshotDate]
257
+
258
+ ---
259
+
260
+ ## Colour Palette
261
+
262
+ ### [Group name — derived from paint style name prefix, e.g. "Pedestal Mini/..." → "Pedestal Mini"]
263
+
264
+ If all styles share the same prefix (e.g. `Pedestal Mini/Black`) group them under that prefix.
265
+ If styles have no prefix group, list them all under a single "Colours" heading.
266
+
267
+ | Token | Hex | Swatch |
268
+ |-------|-----|--------|
269
+ | [name (without prefix)] | `[hex]` | <div style="width:48px;height:24px;background:[hex];border:1px solid #e0e0e0;border-radius:4px"></div> |
270
+
271
+ Repeat for each group, sorted alphabetically within each group.
272
+
273
+ ---
274
+
275
+ ## Typography
276
+
277
+ **Font family:** [family name(s), comma-separated if multiple]
278
+ **Weights used:** [list weights as Regular (400), Medium (500), etc.]
279
+
280
+ ### Desktop
281
+
282
+ | Style | Size | Weight | Line Height | Letter Spacing |
283
+ |-------|------|--------|-------------|----------------|
284
+ [one row per DSK/* style, sorted by name]
285
+
286
+ ### Mobile
287
+
288
+ | Style | Size | Weight | Line Height | Letter Spacing |
289
+ |-------|------|--------|-------------|----------------|
290
+ [one row per MBL/* style, sorted by name]
291
+
292
+ [If styles use a different naming convention than DSK/MBL, use a single unsplit table instead]
293
+
294
+ ---
295
+
296
+ ## Variables
297
+
298
+ [Only include this section if vars.length > 0]
299
+
300
+ ### [Collection name]
301
+
302
+ | Variable | Type | [Mode 1] | [Mode 2] |
303
+ |----------|------|----------|----------|
304
+ [one row per variable, sorted by name within collection]
305
+
306
+ ---
307
+
308
+ ## Breakpoints & Canvas
309
+
310
+ | Breakpoint | Canvas Width |
311
+ |------------|-------------|
312
+ [derived from frames — look for the widest desktop frame and widest mobile frame]
313
+
314
+ ---
315
+
316
+ ## Spacing & Layout
317
+
318
+ Extract these values from the component frame data. Produce a flat table — one row per spacing token.
319
+ Derive tokens by inspecting named component frames for padding/gap values.
320
+
321
+ | Token | Desktop | Mobile |
322
+ |-------|---------|--------|
323
+ | Page side padding | [px] | [px] |
324
+ | Nav vertical padding | [px] | [px] |
325
+ | Section vertical padding | [px] | [px] |
326
+ | [etc — only include rows you have data for] | | |
327
+
328
+ ---
329
+
330
+ ## Components
331
+
332
+ For each named top-level child of the component frame, write a subsection.
333
+ Use the frame name as the heading. Include:
334
+ - A spec table of key layout properties (padding, gap, border-radius, fill, stroke, dimensions)
335
+ - A brief plain-English description of the component's visual behaviour (1–2 sentences)
336
+
337
+ ### [Component Name]
338
+
339
+ [1–2 sentence description]
340
+
341
+ | Property | Value |
342
+ |----------|-------|
343
+ | Width | [px] |
344
+ | Height | [px] |
345
+ | Border radius | [px] |
346
+ | Fill | [hex or "none"] |
347
+ | Stroke | [hex, weight, sides] |
348
+ | Padding | [top / right / bottom / left in px] |
349
+ | Gap | [px] |
350
+
351
+ ---
352
+
353
+ ## Frame Inventory
354
+
355
+ All top-level frames on this Figma page, sorted alphabetically.
356
+
357
+ | Frame | Node ID | Width | Height |
358
+ |-------|---------|-------|--------|
359
+ [one row per frame from tokens.frames]
360
+
361
+ ---
362
+
363
+ ## Figma Source
364
+
365
+ | Field | Value |
366
+ |-------|-------|
367
+ | File key | `[fileKey]` |
368
+ | Page ID | `[figmaPageId]` |
369
+ | Component frame | `[name]` (`[nodeId]`) |
370
+ | Snapshot date | [snapshotDate] |
371
+ ```
372
+
373
+ ---
374
+
375
+ ## Step 6 — Verify
376
+
377
+ After writing the file:
378
+ 1. Confirm the file was written successfully.
379
+ 2. Report which sections were populated and which were empty (e.g. "0 variables found").
380
+ 3. Tell the user the snapshot date and how to commit it: `git add design.md && git commit -m "design: snapshot [date]"`.
381
+
382
+ ---
383
+
384
+ ## Notes for future runs
385
+
386
+ - The output is intentionally **idempotent**: running the skill twice on the same day with no Figma changes should produce an identical file (no diff).
387
+ - Running it after a designer makes changes will produce a clean, readable diff showing exactly what tokens or component values changed.
388
+ - The `.design-snapshot.json` config file should also be committed so any team member can regenerate the snapshot.
389
+ - If the Figma file gains new paint styles, text styles, or component frames, they will appear in the diff as additions.
390
+ - If items are renamed in Figma, they will appear as a deletion + addition in the diff (because output is sorted by name).