@3xhaust/oh-my-design 0.16.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.
- package/LICENSE +21 -0
- package/README.ko.md +282 -0
- package/README.md +282 -0
- package/adapters/build.ts +184 -0
- package/adapters/claude.ts +111 -0
- package/adapters/codex.ts +68 -0
- package/adapters/tokens.ts +21 -0
- package/adapters/tool-map.json +7 -0
- package/bin/omd-install.ts +76 -0
- package/bin/omd.ts +1774 -0
- package/core/asset-sourcing/index.ts +187 -0
- package/core/coach/index.ts +98 -0
- package/core/composition/app-shell-workbench.md +168 -0
- package/core/composition/asymmetric-diagonal-grid.md +178 -0
- package/core/composition/bento-grid.md +223 -0
- package/core/composition/editorial-index-labels.md +173 -0
- package/core/composition/form-wizard-stepper.md +132 -0
- package/core/composition/master-detail-flow.md +129 -0
- package/core/composition/section-inversion.md +182 -0
- package/core/composition/sidebar-margin-annotation.md +184 -0
- package/core/composition/split-screen-hero.md +224 -0
- package/core/composition/sticky-sidebar-scroll.md +219 -0
- package/core/composition/typographic-hero.md +100 -0
- package/core/composition-contract/index.ts +233 -0
- package/core/composition-contract/visual-richness.ts +101 -0
- package/core/config/index.ts +27 -0
- package/core/copy/index.ts +296 -0
- package/core/craft/finish-pass.md +356 -0
- package/core/craft/index.ts +33 -0
- package/core/design/index.ts +581 -0
- package/core/design/interaction-states.ts +189 -0
- package/core/eval-harness/index.ts +225 -0
- package/core/evidence/final.ts +444 -0
- package/core/evidence/task.ts +459 -0
- package/core/figma/client.ts +312 -0
- package/core/figma/diff.ts +261 -0
- package/core/figma/responsive.ts +310 -0
- package/core/figma/system.ts +466 -0
- package/core/figma/types.ts +185 -0
- package/core/frame/check-ux.ts +180 -0
- package/core/frame/index.ts +39 -0
- package/core/frame/write.ts +168 -0
- package/core/graphics/css-illustration-primitives.md +189 -0
- package/core/graphics/duotone-image-presets.md +173 -0
- package/core/graphics/gradient-mesh.md +178 -0
- package/core/graphics/noise-grain-texture.md +150 -0
- package/core/graphics/placeholder-policy.md +185 -0
- package/core/graphics/svg-geometric-patterns.md +145 -0
- package/core/history/index.ts +39 -0
- package/core/install/detect.ts +32 -0
- package/core/install/install.ts +321 -0
- package/core/install/patch-claude.ts +109 -0
- package/core/install/patch-codex.ts +82 -0
- package/core/interaction/index.ts +101 -0
- package/core/interaction/recipes/signature-lighting.md +203 -0
- package/core/ir/dom.ts +352 -0
- package/core/ir/normalize.ts +177 -0
- package/core/motion/easing.md +151 -0
- package/core/motion/energy.ts +188 -0
- package/core/motion/recipes/image-hover-distortion.md +198 -0
- package/core/motion/recipes/magnetic-hover.md +229 -0
- package/core/motion/recipes/marquee.md +202 -0
- package/core/motion/recipes/number-counter.md +242 -0
- package/core/motion/recipes/page-loader.md +240 -0
- package/core/motion/recipes/parallax.md +233 -0
- package/core/motion/recipes/scroll-reveal.md +210 -0
- package/core/motion/recipes/section-color-inversion.md +217 -0
- package/core/motion/recipes/split-text-entrance.md +222 -0
- package/core/motion/recipes/stagger-orchestrator.md +227 -0
- package/core/motion/recipes/sticky-scene-transition.md +246 -0
- package/core/motion/recipes/view-transitions.md +239 -0
- package/core/probe/index.ts +186 -0
- package/core/probe/schema.json +46 -0
- package/core/protocol/composition-contract.md +192 -0
- package/core/protocol/copy-deck.md +96 -0
- package/core/protocol/human-design-loop.md +447 -0
- package/core/protocol/slop-review.md +71 -0
- package/core/ref/batch.ts +94 -0
- package/core/ref/blueprint.ts +199 -0
- package/core/ref/distance.ts +225 -0
- package/core/ref/invariants.ts +162 -0
- package/core/ref/signal.ts +37 -0
- package/core/ref/store.ts +122 -0
- package/core/render/index.ts +607 -0
- package/core/rules/attribution.ts +159 -0
- package/core/rules/builtin/contrast.yaml +8 -0
- package/core/rules/builtin/focus.yaml +8 -0
- package/core/rules/builtin/hit-area.yaml +8 -0
- package/core/rules/builtin/ko.yaml +6 -0
- package/core/rules/builtin/motion.yaml +80 -0
- package/core/rules/builtin/slop.yaml +365 -0
- package/core/rules/builtin/spacing.yaml +9 -0
- package/core/rules/builtin/system.yaml +42 -0
- package/core/rules/builtin/token.yaml +8 -0
- package/core/rules/builtin/ux.yaml +125 -0
- package/core/rules/engine.ts +88 -0
- package/core/rules/leakage.ts +72 -0
- package/core/rules/motion-spec.ts +204 -0
- package/core/site/index.ts +100 -0
- package/core/slop/index.ts +427 -0
- package/core/slop/text-slop.ts +220 -0
- package/core/source-seal/index.ts +174 -0
- package/core/stack/index.ts +53 -0
- package/core/target/index.ts +183 -0
- package/core/theory/color.md +285 -0
- package/core/theory/components.md +272 -0
- package/core/theory/craft.md +270 -0
- package/core/theory/expressive.md +213 -0
- package/core/theory/imagegen.md +105 -0
- package/core/theory/layout.md +159 -0
- package/core/theory/motion.md +283 -0
- package/core/theory/typography.md +134 -0
- package/core/theory/ux.md +580 -0
- package/core/theory/voice.md +595 -0
- package/core/types.ts +684 -0
- package/package.json +40 -0
- package/src/agents/composer.agent.yaml +174 -0
- package/src/agents/eye.agent.yaml +208 -0
- package/src/agents/framer.agent.yaml +52 -0
- package/src/agents/glance.agent.yaml +19 -0
- package/src/agents/hand.agent.yaml +200 -0
- package/src/agents/scout.agent.yaml +71 -0
- package/src/agents/sketch.agent.yaml +64 -0
- package/src/agents/typesetter.agent.yaml +52 -0
- package/src/agents/writer.agent.yaml +63 -0
- package/src/skills/omd-coach/SKILL.md +40 -0
- package/src/skills/omd-critique/SKILL.md +77 -0
- package/src/skills/omd-figma/SKILL.md +256 -0
- package/src/skills/omd-humanize/SKILL.md +160 -0
- package/src/skills/omd-scout/SKILL.md +77 -0
- package/src/skills/omd-ultradesign/SKILL.md +521 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design-system synthesis — F2 implementation.
|
|
3
|
+
*
|
|
4
|
+
* All functions are pure (no I/O) so they can be unit-tested against fixtures.
|
|
5
|
+
*
|
|
6
|
+
* Token naming heuristic (--color-bg / --color-fg):
|
|
7
|
+
* 1. Find the dominant background: most-frequent color with WCAG relative
|
|
8
|
+
* luminance > 0.8 (very light) or < 0.1 (very dark). If none qualifies,
|
|
9
|
+
* fall back to the most-frequent color overall.
|
|
10
|
+
* 2. Determine bg polarity:
|
|
11
|
+
* light bg (lum > 0.5) → most-frequent dark color (lum < 0.4) = --color-fg
|
|
12
|
+
* dark bg (lum ≤ 0.5) → most-frequent light color (lum > 0.6) = --color-fg
|
|
13
|
+
* 3. Remaining colors (by frequency) → --color-accent-1, --color-accent-2, …
|
|
14
|
+
*/
|
|
15
|
+
import type { FigmaSnapshot, SnapshotNode } from './types.ts';
|
|
16
|
+
|
|
17
|
+
// ── Variant parsing ───────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a Figma variant component name into a property map.
|
|
21
|
+
*
|
|
22
|
+
* "Size=sm, State=hover" → { Size: 'sm', State: 'hover' }
|
|
23
|
+
* "Primary" → {} (no = pairs — not an error)
|
|
24
|
+
* "Size=md" → { Size: 'md' }
|
|
25
|
+
*/
|
|
26
|
+
export function parseVariantName(name: string): Record<string, string> {
|
|
27
|
+
const result: Record<string, string> = {};
|
|
28
|
+
for (const segment of name.split(',')) {
|
|
29
|
+
const trimmed = segment.trim();
|
|
30
|
+
const eq = trimmed.indexOf('=');
|
|
31
|
+
if (eq === -1) continue;
|
|
32
|
+
const key = trimmed.slice(0, eq).trim();
|
|
33
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
34
|
+
if (key.length > 0) result[key] = value;
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Component matrix ──────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface ComponentVariant {
|
|
42
|
+
componentId: string;
|
|
43
|
+
name: string;
|
|
44
|
+
properties: Record<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ComponentSetEntry {
|
|
48
|
+
setId: string;
|
|
49
|
+
setName: string;
|
|
50
|
+
variants: ComponentVariant[];
|
|
51
|
+
/** Unique property keys found across variants, e.g. ["Size", "State"] */
|
|
52
|
+
propertyKeys: string[];
|
|
53
|
+
/** Unique values per property key */
|
|
54
|
+
propertyValues: Record<string, string[]>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a component × variant-property matrix from the snapshot's component set metadata.
|
|
59
|
+
* Variant names come from the file-level `components` map (stored in snapshot.componentSets).
|
|
60
|
+
*/
|
|
61
|
+
export function buildComponentMatrix(snapshot: FigmaSnapshot): ComponentSetEntry[] {
|
|
62
|
+
const entries: ComponentSetEntry[] = [];
|
|
63
|
+
|
|
64
|
+
for (const cs of Object.values(snapshot.componentSets)) {
|
|
65
|
+
const variants: ComponentVariant[] = cs.variants.map((v) => ({
|
|
66
|
+
componentId: v.componentId,
|
|
67
|
+
name: v.name,
|
|
68
|
+
properties: parseVariantName(v.name),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
const propertyKeySet = new Set<string>();
|
|
72
|
+
const propertyValueSets: Record<string, Set<string>> = {};
|
|
73
|
+
for (const v of variants) {
|
|
74
|
+
for (const [k, val] of Object.entries(v.properties)) {
|
|
75
|
+
propertyKeySet.add(k);
|
|
76
|
+
if (propertyValueSets[k] === undefined) propertyValueSets[k] = new Set();
|
|
77
|
+
propertyValueSets[k]!.add(val);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const propertyKeys = [...propertyKeySet];
|
|
82
|
+
const propertyValues: Record<string, string[]> = {};
|
|
83
|
+
for (const k of propertyKeys) {
|
|
84
|
+
propertyValues[k] = [...(propertyValueSets[k] ?? [])];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
entries.push({ setId: cs.id, setName: cs.name, variants, propertyKeys, propertyValues });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Token extraction ──────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
export interface ColorToken {
|
|
96
|
+
hex: string;
|
|
97
|
+
count: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ShadowToken {
|
|
101
|
+
color: string;
|
|
102
|
+
offsetX: number;
|
|
103
|
+
offsetY: number;
|
|
104
|
+
radius: number;
|
|
105
|
+
count: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DesignTokens {
|
|
109
|
+
/** Solid fill colors, deduped by clustering, sorted by frequency desc */
|
|
110
|
+
colors: ColorToken[];
|
|
111
|
+
/** Unique font sizes, sorted asc */
|
|
112
|
+
typeScale: number[];
|
|
113
|
+
/** Unique spacing values (itemSpacing + padding), sorted asc */
|
|
114
|
+
spacing: number[];
|
|
115
|
+
/** Unique corner radii > 0, sorted asc */
|
|
116
|
+
radii: number[];
|
|
117
|
+
/** Drop/inner shadows, sorted by frequency desc */
|
|
118
|
+
shadows: ShadowToken[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Euclidean distance between two #RRGGBB hex colors in RGB space.
|
|
123
|
+
* Maximum possible distance ≈ 441 (black to white).
|
|
124
|
+
*/
|
|
125
|
+
export function hexColorDistance(a: string, b: string): number {
|
|
126
|
+
const parse = (h: string): [number, number, number] => [
|
|
127
|
+
parseInt(h.slice(1, 3), 16),
|
|
128
|
+
parseInt(h.slice(3, 5), 16),
|
|
129
|
+
parseInt(h.slice(5, 7), 16),
|
|
130
|
+
];
|
|
131
|
+
const [ar, ag, ab] = parse(a);
|
|
132
|
+
const [br, bg, bb] = parse(b);
|
|
133
|
+
return Math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Cluster near-identical colors.
|
|
138
|
+
*
|
|
139
|
+
* Colors within euclidean RGB distance < `threshold` are merged into one
|
|
140
|
+
* representative (the first seen, which is the most-frequent after sorting).
|
|
141
|
+
* Returns a list sorted by count descending.
|
|
142
|
+
*
|
|
143
|
+
* @param colors Array of #RRGGBB strings (may contain duplicates).
|
|
144
|
+
* @param threshold Euclidean RGB distance below which two colors are considered
|
|
145
|
+
* identical. Default 15 (~6% of full 0-255 range per channel).
|
|
146
|
+
*/
|
|
147
|
+
export function clusterColors(colors: string[], threshold = 15): ColorToken[] {
|
|
148
|
+
// Count raw occurrences
|
|
149
|
+
const freq = new Map<string, number>();
|
|
150
|
+
for (const c of colors) freq.set(c, (freq.get(c) ?? 0) + 1);
|
|
151
|
+
|
|
152
|
+
// Sort by frequency desc so the most-used representative survives
|
|
153
|
+
const ranked = [...freq.entries()].sort((a, b) => b[1] - a[1]);
|
|
154
|
+
const clusters: ColorToken[] = [];
|
|
155
|
+
|
|
156
|
+
for (const [hex, count] of ranked) {
|
|
157
|
+
const existing = clusters.find((c) => hexColorDistance(c.hex, hex) < threshold);
|
|
158
|
+
if (existing !== undefined) {
|
|
159
|
+
existing.count += count;
|
|
160
|
+
} else {
|
|
161
|
+
clusters.push({ hex, count });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return clusters.sort((a, b) => b.count - a.count);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Flatten all SnapshotNodes from all pages and frames. */
|
|
169
|
+
function allNodes(snapshot: FigmaSnapshot): SnapshotNode[] {
|
|
170
|
+
return snapshot.pages.flatMap((p) => p.frames.flatMap((f) => f.nodes));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Extract design tokens from a normalized snapshot. */
|
|
174
|
+
export function extractTokens(snapshot: FigmaSnapshot): DesignTokens {
|
|
175
|
+
const nodes = allNodes(snapshot);
|
|
176
|
+
|
|
177
|
+
// Colors: solid fills only
|
|
178
|
+
const rawColors: string[] = [];
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
for (const fill of node.fills) {
|
|
181
|
+
if (fill.type === 'solid' && fill.hex !== undefined) rawColors.push(fill.hex);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const colors = clusterColors(rawColors);
|
|
185
|
+
|
|
186
|
+
// Type scale: unique font sizes
|
|
187
|
+
const fontSizes = new Set<number>();
|
|
188
|
+
for (const node of nodes) {
|
|
189
|
+
if (node.typography !== undefined) fontSizes.add(node.typography.fontSize);
|
|
190
|
+
}
|
|
191
|
+
const typeScale = [...fontSizes].sort((a, b) => a - b);
|
|
192
|
+
|
|
193
|
+
// Spacing: itemSpacing + individual padding values > 0
|
|
194
|
+
const spacingSet = new Set<number>();
|
|
195
|
+
for (const node of nodes) {
|
|
196
|
+
if (node.itemSpacing !== undefined && node.itemSpacing > 0) spacingSet.add(node.itemSpacing);
|
|
197
|
+
if (node.padding !== undefined) {
|
|
198
|
+
for (const p of node.padding) {
|
|
199
|
+
if (p > 0) spacingSet.add(p);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const spacing = [...spacingSet].sort((a, b) => a - b);
|
|
204
|
+
|
|
205
|
+
// Radii
|
|
206
|
+
const radiiSet = new Set<number>();
|
|
207
|
+
for (const node of nodes) {
|
|
208
|
+
if (node.cornerRadius !== undefined && node.cornerRadius > 0) radiiSet.add(node.cornerRadius);
|
|
209
|
+
}
|
|
210
|
+
const radii = [...radiiSet].sort((a, b) => a - b);
|
|
211
|
+
|
|
212
|
+
// Shadows: drop_shadow and inner_shadow effects
|
|
213
|
+
const shadowMap = new Map<string, ShadowToken>();
|
|
214
|
+
for (const node of nodes) {
|
|
215
|
+
for (const effect of node.effects) {
|
|
216
|
+
if (
|
|
217
|
+
(effect.type === 'drop_shadow' || effect.type === 'inner_shadow')
|
|
218
|
+
&& effect.color !== undefined
|
|
219
|
+
&& effect.offsetX !== undefined
|
|
220
|
+
&& effect.offsetY !== undefined
|
|
221
|
+
&& effect.radius !== undefined
|
|
222
|
+
) {
|
|
223
|
+
const key = `${effect.color}|${effect.offsetX}|${effect.offsetY}|${effect.radius}`;
|
|
224
|
+
const existing = shadowMap.get(key);
|
|
225
|
+
if (existing !== undefined) {
|
|
226
|
+
existing.count++;
|
|
227
|
+
} else {
|
|
228
|
+
shadowMap.set(key, {
|
|
229
|
+
color: effect.color,
|
|
230
|
+
offsetX: effect.offsetX,
|
|
231
|
+
offsetY: effect.offsetY,
|
|
232
|
+
radius: effect.radius,
|
|
233
|
+
count: 1,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const shadows = [...shadowMap.values()].sort((a, b) => b.count - a.count);
|
|
240
|
+
|
|
241
|
+
return { colors, typeScale, spacing, radii, shadows };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── CSS generation ────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compute WCAG relative luminance of a #RRGGBB hex color.
|
|
248
|
+
* Returns a value in [0, 1]: 0 = black, 1 = white.
|
|
249
|
+
*/
|
|
250
|
+
export function relativeLuminance(hex: string): number {
|
|
251
|
+
const linearise = (c: number): number =>
|
|
252
|
+
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
253
|
+
const r = linearise(parseInt(hex.slice(1, 3), 16) / 255);
|
|
254
|
+
const g = linearise(parseInt(hex.slice(3, 5), 16) / 255);
|
|
255
|
+
const b = linearise(parseInt(hex.slice(5, 7), 16) / 255);
|
|
256
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Generate a :root CSS custom properties file from extracted tokens.
|
|
261
|
+
*
|
|
262
|
+
* See module-level doc-comment for the token naming heuristic.
|
|
263
|
+
*/
|
|
264
|
+
export function generateCss(tokens: DesignTokens): string {
|
|
265
|
+
const { colors, typeScale, spacing, radii, shadows } = tokens;
|
|
266
|
+
|
|
267
|
+
const lines: string[] = [
|
|
268
|
+
'/* Design tokens extracted from Figma snapshot by `omd figma system`.',
|
|
269
|
+
' *',
|
|
270
|
+
' * Naming heuristic for color tokens:',
|
|
271
|
+
' * --color-bg : most-frequent color with luminance > 0.8 (light) or < 0.1 (dark).',
|
|
272
|
+
' * Falls back to the most-frequent color if no extreme is found.',
|
|
273
|
+
' * --color-fg : most-frequent dark color (lum < 0.4) when bg is light,',
|
|
274
|
+
' * or most-frequent light color (lum > 0.6) when bg is dark.',
|
|
275
|
+
' * --color-accent-N : remaining colors by frequency.',
|
|
276
|
+
' */',
|
|
277
|
+
':root {',
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
if (colors.length > 0) {
|
|
281
|
+
// Find dominant background
|
|
282
|
+
let bgIdx = colors.findIndex((c) => {
|
|
283
|
+
const lum = relativeLuminance(c.hex);
|
|
284
|
+
return lum > 0.8 || lum < 0.1;
|
|
285
|
+
});
|
|
286
|
+
if (bgIdx === -1) bgIdx = 0; // fallback: most-frequent
|
|
287
|
+
|
|
288
|
+
const bgColor = colors[bgIdx]!;
|
|
289
|
+
const bgLum = relativeLuminance(bgColor.hex);
|
|
290
|
+
|
|
291
|
+
lines.push(' /* Background */');
|
|
292
|
+
lines.push(` --color-bg: ${bgColor.hex};`);
|
|
293
|
+
|
|
294
|
+
const rest = colors.filter((_, i) => i !== bgIdx);
|
|
295
|
+
|
|
296
|
+
// Find foreground among the remaining
|
|
297
|
+
let fgIdx: number;
|
|
298
|
+
if (bgLum > 0.5) {
|
|
299
|
+
// light bg → dark fg
|
|
300
|
+
fgIdx = rest.findIndex((c) => relativeLuminance(c.hex) < 0.4);
|
|
301
|
+
} else {
|
|
302
|
+
// dark bg → light fg
|
|
303
|
+
fgIdx = rest.findIndex((c) => relativeLuminance(c.hex) > 0.6);
|
|
304
|
+
}
|
|
305
|
+
if (fgIdx === -1 && rest.length > 0) fgIdx = 0;
|
|
306
|
+
|
|
307
|
+
if (rest.length > 0 && fgIdx >= 0) {
|
|
308
|
+
lines.push(' /* Foreground */');
|
|
309
|
+
lines.push(` --color-fg: ${rest[fgIdx]!.hex};`);
|
|
310
|
+
|
|
311
|
+
const accents = rest.filter((_, i) => i !== fgIdx);
|
|
312
|
+
if (accents.length > 0) {
|
|
313
|
+
lines.push(' /* Accent */');
|
|
314
|
+
for (let i = 0; i < accents.length; i++) {
|
|
315
|
+
lines.push(` --color-accent-${i + 1}: ${accents[i]!.hex};`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
for (let i = 0; i < rest.length; i++) {
|
|
320
|
+
lines.push(` --color-accent-${i + 1}: ${rest[i]!.hex};`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (typeScale.length > 0) {
|
|
326
|
+
lines.push(' /* Type scale */');
|
|
327
|
+
for (let i = 0; i < typeScale.length; i++) {
|
|
328
|
+
lines.push(` --type-scale-${i + 1}: ${typeScale[i]!}px;`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (spacing.length > 0) {
|
|
333
|
+
lines.push(' /* Spacing */');
|
|
334
|
+
for (let i = 0; i < spacing.length; i++) {
|
|
335
|
+
lines.push(` --spacing-${i + 1}: ${spacing[i]!}px;`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (radii.length > 0) {
|
|
340
|
+
lines.push(' /* Corner radius */');
|
|
341
|
+
for (let i = 0; i < radii.length; i++) {
|
|
342
|
+
lines.push(` --radius-${i + 1}: ${radii[i]!}px;`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (shadows.length > 0) {
|
|
347
|
+
lines.push(' /* Shadows */');
|
|
348
|
+
const top = shadows.slice(0, 5);
|
|
349
|
+
for (let i = 0; i < top.length; i++) {
|
|
350
|
+
const s = top[i]!;
|
|
351
|
+
lines.push(` --shadow-${i + 1}: ${s.offsetX}px ${s.offsetY}px ${s.radius}px ${s.color};`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
lines.push('}');
|
|
356
|
+
return lines.join('\n');
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Markdown generation ───────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
function mdTable(headers: string[], rows: string[][]): string {
|
|
362
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
363
|
+
const pad = (s: string, w: number) => s.padEnd(w);
|
|
364
|
+
const headerRow = `| ${headers.map((h, i) => pad(h, widths[i]!)).join(' | ')} |`;
|
|
365
|
+
const sepRow = `| ${widths.map((w) => '-'.repeat(w)).join(' | ')} |`;
|
|
366
|
+
const dataRows = rows.map((r) => `| ${r.map((c, i) => pad(c, widths[i]!)).join(' | ')} |`);
|
|
367
|
+
return [headerRow, sepRow, ...dataRows].join('\n');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Generate a human-readable .omd/figma/design-system.md from tokens and matrix.
|
|
372
|
+
*/
|
|
373
|
+
export function generateMarkdown(
|
|
374
|
+
tokens: DesignTokens,
|
|
375
|
+
matrix: ComponentSetEntry[],
|
|
376
|
+
fileName: string,
|
|
377
|
+
): string {
|
|
378
|
+
const sections: string[] = [
|
|
379
|
+
`# Design System — ${fileName}`,
|
|
380
|
+
'',
|
|
381
|
+
'_Generated by `omd figma system`_',
|
|
382
|
+
'',
|
|
383
|
+
];
|
|
384
|
+
|
|
385
|
+
// Color palette
|
|
386
|
+
sections.push('## Color Palette', '');
|
|
387
|
+
if (tokens.colors.length > 0) {
|
|
388
|
+
sections.push(mdTable(['Hex', 'Count'], tokens.colors.map((c) => [c.hex, String(c.count)])));
|
|
389
|
+
} else {
|
|
390
|
+
sections.push('_No solid fills found._');
|
|
391
|
+
}
|
|
392
|
+
sections.push('');
|
|
393
|
+
|
|
394
|
+
// Type scale
|
|
395
|
+
sections.push('## Type Scale', '');
|
|
396
|
+
if (tokens.typeScale.length > 0) {
|
|
397
|
+
sections.push(mdTable(['Step', 'Size'], tokens.typeScale.map((s, i) => [String(i + 1), `${s}px`])));
|
|
398
|
+
} else {
|
|
399
|
+
sections.push('_No text nodes found._');
|
|
400
|
+
}
|
|
401
|
+
sections.push('');
|
|
402
|
+
|
|
403
|
+
// Spacing
|
|
404
|
+
sections.push('## Spacing', '');
|
|
405
|
+
if (tokens.spacing.length > 0) {
|
|
406
|
+
sections.push(tokens.spacing.map((s) => `- ${s}px`).join('\n'));
|
|
407
|
+
} else {
|
|
408
|
+
sections.push('_No auto-layout spacing found._');
|
|
409
|
+
}
|
|
410
|
+
sections.push('');
|
|
411
|
+
|
|
412
|
+
// Radii
|
|
413
|
+
sections.push('## Corner Radii', '');
|
|
414
|
+
if (tokens.radii.length > 0) {
|
|
415
|
+
sections.push(tokens.radii.map((r) => `- ${r}px`).join('\n'));
|
|
416
|
+
} else {
|
|
417
|
+
sections.push('_No corner radii found._');
|
|
418
|
+
}
|
|
419
|
+
sections.push('');
|
|
420
|
+
|
|
421
|
+
// Shadows
|
|
422
|
+
sections.push('## Shadows', '');
|
|
423
|
+
if (tokens.shadows.length > 0) {
|
|
424
|
+
const top = tokens.shadows.slice(0, 10);
|
|
425
|
+
sections.push(
|
|
426
|
+
top
|
|
427
|
+
.map((s) => `- \`${s.offsetX}px ${s.offsetY}px ${s.radius}px ${s.color}\` (×${s.count})`)
|
|
428
|
+
.join('\n'),
|
|
429
|
+
);
|
|
430
|
+
} else {
|
|
431
|
+
sections.push('_No shadows found._');
|
|
432
|
+
}
|
|
433
|
+
sections.push('');
|
|
434
|
+
|
|
435
|
+
// Component inventory
|
|
436
|
+
sections.push('## Component Inventory', '');
|
|
437
|
+
if (matrix.length === 0) {
|
|
438
|
+
sections.push('_No component sets found._');
|
|
439
|
+
sections.push('');
|
|
440
|
+
} else {
|
|
441
|
+
for (const cs of matrix) {
|
|
442
|
+
sections.push(`### ${cs.setName}`, '');
|
|
443
|
+
if (cs.variants.length === 0) {
|
|
444
|
+
sections.push('_No variants found._');
|
|
445
|
+
sections.push('');
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const keys = cs.propertyKeys;
|
|
450
|
+
if (keys.length === 0) {
|
|
451
|
+
sections.push(`${cs.variants.length} variant(s) — names contain no key=value properties.`);
|
|
452
|
+
sections.push('');
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const rows = cs.variants.map((v) => [
|
|
457
|
+
v.name,
|
|
458
|
+
...keys.map((k) => v.properties[k] ?? '—'),
|
|
459
|
+
]);
|
|
460
|
+
sections.push(mdTable(['Variant', ...keys], rows));
|
|
461
|
+
sections.push('');
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return sections.join('\n');
|
|
466
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the Figma REST API (minimal subset) and the normalized snapshot
|
|
3
|
+
* stored at .omd/figma/snapshot.json.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Raw Figma API shapes ──────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface FigmaColor {
|
|
9
|
+
r: number;
|
|
10
|
+
g: number;
|
|
11
|
+
b: number;
|
|
12
|
+
a: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface FigmaColorStop {
|
|
16
|
+
color: FigmaColor;
|
|
17
|
+
position: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface FigmaPaint {
|
|
21
|
+
type: string; // 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | ...
|
|
22
|
+
visible?: boolean;
|
|
23
|
+
opacity?: number;
|
|
24
|
+
color?: FigmaColor;
|
|
25
|
+
gradientStops?: FigmaColorStop[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface FigmaEffect {
|
|
29
|
+
type: string; // 'DROP_SHADOW' | 'INNER_SHADOW' | 'LAYER_BLUR' | ...
|
|
30
|
+
visible?: boolean;
|
|
31
|
+
color?: FigmaColor;
|
|
32
|
+
offset?: { x: number; y: number };
|
|
33
|
+
radius?: number;
|
|
34
|
+
spread?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface FigmaTypeStyle {
|
|
38
|
+
fontFamily?: string;
|
|
39
|
+
fontSize?: number;
|
|
40
|
+
fontWeight?: number;
|
|
41
|
+
lineHeightPx?: number;
|
|
42
|
+
lineHeightUnit?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FigmaNode {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
type: string;
|
|
49
|
+
children?: FigmaNode[];
|
|
50
|
+
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
|
|
51
|
+
fills?: FigmaPaint[];
|
|
52
|
+
effects?: FigmaEffect[];
|
|
53
|
+
cornerRadius?: number;
|
|
54
|
+
style?: FigmaTypeStyle;
|
|
55
|
+
layoutMode?: string; // 'NONE' | 'HORIZONTAL' | 'VERTICAL'
|
|
56
|
+
itemSpacing?: number;
|
|
57
|
+
paddingLeft?: number;
|
|
58
|
+
paddingRight?: number;
|
|
59
|
+
paddingTop?: number;
|
|
60
|
+
paddingBottom?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface FigmaComponentMeta {
|
|
64
|
+
key?: string;
|
|
65
|
+
name: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
componentSetId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FigmaComponentSetMeta {
|
|
71
|
+
key?: string;
|
|
72
|
+
name: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface FigmaFileResponse {
|
|
77
|
+
name: string;
|
|
78
|
+
document: FigmaNode;
|
|
79
|
+
styles?: Record<string, { name: string; styleType: string }>;
|
|
80
|
+
components?: Record<string, FigmaComponentMeta>;
|
|
81
|
+
componentSets?: Record<string, FigmaComponentSetMeta>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface FigmaNodesResponse {
|
|
85
|
+
nodes: Record<string, { document: FigmaNode } | null>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Normalized snapshot ───────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export interface SnapshotFill {
|
|
91
|
+
type: string;
|
|
92
|
+
/** Solid color as #RRGGBB */
|
|
93
|
+
hex?: string;
|
|
94
|
+
/** Gradient description string for gradient fills */
|
|
95
|
+
gradient?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SnapshotEffect {
|
|
99
|
+
type: string;
|
|
100
|
+
/** Shadow color as #RRGGBB */
|
|
101
|
+
color?: string;
|
|
102
|
+
offsetX?: number;
|
|
103
|
+
offsetY?: number;
|
|
104
|
+
radius?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface SnapshotTypography {
|
|
108
|
+
fontFamily: string;
|
|
109
|
+
fontSize: number;
|
|
110
|
+
fontWeight: number;
|
|
111
|
+
/** Line height as ratio to font-size, e.g. 1.5 */
|
|
112
|
+
lineHeight: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SnapshotNode {
|
|
116
|
+
id: string;
|
|
117
|
+
name: string;
|
|
118
|
+
type: string;
|
|
119
|
+
absoluteBoundingBox?: { x: number; y: number; width: number; height: number };
|
|
120
|
+
fills: SnapshotFill[];
|
|
121
|
+
effects: SnapshotEffect[];
|
|
122
|
+
typography?: SnapshotTypography;
|
|
123
|
+
cornerRadius?: number;
|
|
124
|
+
layoutMode?: 'HORIZONTAL' | 'VERTICAL';
|
|
125
|
+
itemSpacing?: number;
|
|
126
|
+
/** top, right, bottom, left */
|
|
127
|
+
padding?: [number, number, number, number];
|
|
128
|
+
/** Present when this node belongs to a component set */
|
|
129
|
+
componentSetId?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface SnapshotFrame {
|
|
133
|
+
id: string;
|
|
134
|
+
name: string;
|
|
135
|
+
nodes: SnapshotNode[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface SnapshotPage {
|
|
139
|
+
id: string;
|
|
140
|
+
name: string;
|
|
141
|
+
frames: SnapshotFrame[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface ComponentSetInfo {
|
|
145
|
+
id: string;
|
|
146
|
+
name: string;
|
|
147
|
+
/** Component node IDs belonging to this set */
|
|
148
|
+
variants: Array<{ componentId: string; name: string }>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Responsive matching ───────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
/** Viewport band derived from frame width. */
|
|
154
|
+
export type Band = 'mobile' | 'tablet' | 'desktop' | 'unknown';
|
|
155
|
+
|
|
156
|
+
/** One viewport variant within a responsive breakpoint set. */
|
|
157
|
+
export interface FigmaResponsiveVariant {
|
|
158
|
+
frameId: string;
|
|
159
|
+
name: string;
|
|
160
|
+
width: number;
|
|
161
|
+
band: Band;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A group of frames that are viewport variants of the same screen. */
|
|
165
|
+
export interface FigmaBreakpointSet {
|
|
166
|
+
screen: string;
|
|
167
|
+
variants: FigmaResponsiveVariant[];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface FigmaSnapshot {
|
|
171
|
+
fileKey: string;
|
|
172
|
+
fileName: string;
|
|
173
|
+
capturedAt: string;
|
|
174
|
+
pages: SnapshotPage[];
|
|
175
|
+
componentSets: Record<string, ComponentSetInfo>;
|
|
176
|
+
/**
|
|
177
|
+
* Responsive frame groupings computed by `matchResponsiveFrames`.
|
|
178
|
+
* Populated after `omd figma pull` and stored in the snapshot for the skill.
|
|
179
|
+
*/
|
|
180
|
+
responsive?: {
|
|
181
|
+
breakpointSets: FigmaBreakpointSet[];
|
|
182
|
+
/** Frames that did not match any viewport-variant group, honestly labeled. */
|
|
183
|
+
unmatched: FigmaResponsiveVariant[];
|
|
184
|
+
};
|
|
185
|
+
}
|