@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.
- package/CHANGELOG.md +32 -0
- package/package.json +1 -1
- package/skills/site-workflows-apply-design-snapshot/SKILL.md +285 -0
- package/skills/site-workflows-contentful-vercel-setup/SKILL.md +300 -0
- package/skills/site-workflows-copy-doc-snapshot/SKILL.md +222 -0
- package/skills/site-workflows-figma-design-snapshot/SKILL.md +390 -0
- package/skills/site-workflows-new-project/SKILL.md +278 -0
- package/skills/site-workflows-project-cleanup/SKILL.md +362 -0
|
@@ -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).
|